diff --git a/repair/apps/asmfa/graphs/graph.py b/repair/apps/asmfa/graphs/graph.py index 09066c247..e251701db 100644 --- a/repair/apps/asmfa/graphs/graph.py +++ b/repair/apps/asmfa/graphs/graph.py @@ -724,6 +724,7 @@ def _get_affected_flows(self, solution_part): origin__activity = af.origin_activity, destination__activity = af.destination_activity ) + kwargs = { 'strategy_material': af.material.id } diff --git a/repair/apps/asmfa/graphs/graphwalker.py b/repair/apps/asmfa/graphs/graphwalker.py index 1ad106d7e..261f9b4cf 100644 --- a/repair/apps/asmfa/graphs/graphwalker.py +++ b/repair/apps/asmfa/graphs/graphwalker.py @@ -1,48 +1,79 @@ try: import graph_tool as gt - from graph_tool import util, search + from graph_tool import search from graph_tool.search import BFSVisitor except ModuleNotFoundError: class BFSVisitor: pass import copy -import numpy as np -from django.db.models import Q -import time - -from repair.apps.asmfa.models.flows import FractionFlow -from repair.apps.asmfa.models import Actor class NodeVisitor(BFSVisitor): - def __init__(self, name, amount, visited, change, - balance_factor): + def __init__(self, name, amount, change, + balance_factor, forward=True): self.id = name self.amount = amount - self.visited = visited self.change = change self.balance_factor = balance_factor + self.forward = forward + + def examine_vertex(self, u): + vertex_id = int(u) + out_degree = u.out_degree() + if not out_degree: + return + + bf = self.balance_factor[vertex_id] + sum_in_deltas = u.in_degree(weight=self.change) + balanced_delta = sum_in_deltas * bf + sum_out_f = u.out_degree(weight=self.amount) + if sum_out_f: + amount_factor = balanced_delta / sum_out_f + else: + amount_factor = balanced_delta / out_degree - def examine_edge(self, e): - """Compute the amount change on each inflow for the vertex + for e_out in u.out_edges(): + amount_delta = self.amount[e_out] * amount_factor + if self.forward: + self.change[e_out] += amount_delta + else: + if abs(self.change[e_out]) < abs(amount_delta): + self.change[e_out] = amount_delta + else: + self.change[e_out] += amount_delta + + +class NodeVisitorBalanceDeltas(BFSVisitor): + + def __init__(self, name, amount, change, + balance_factor): + self.id = name + self.amount = amount + self.change = change + self.balance_factor = balance_factor - This function is invoked on a edge as it is popped from the queue. - """ - u = e.target() + def examine_vertex(self, u): vertex_id = int(u) + out_degree = u.out_degree() + if not out_degree: + return - balanced_delta = self.change[e] * self.balance_factor[vertex_id] - edges_out = list(u.out_edges()) - sum_out_f = sum(self.amount[out_f] for out_f in edges_out) + sum_in_deltas = u.in_degree(self.change) + sum_out_deltas = u.out_degree(self.change) + bf = self.balance_factor[vertex_id] + balanced_out_deltas = sum_out_deltas / bf + balanced_delta = sum_in_deltas - balanced_out_deltas + if abs(balanced_delta) < 0.0000001: + return + sum_out_f = u.out_degree(weight=self.amount) if sum_out_f: amount_factor = balanced_delta / sum_out_f - elif edges_out: - amount_factor = balanced_delta / len(edges_out) - for e_out in edges_out: - if not (self.visited[e_out] and self.visited[e]): - self.change[e_out] += self.amount[e_out] * amount_factor - self.visited[e_out] = True + else: + amount_factor = balanced_delta / out_degree + for e_out in u.out_edges(): + amount_delta = self.amount[e_out] * amount_factor + self.change[e_out] += amount_delta def traverse_graph(g, edge, delta, upstream=True): @@ -61,47 +92,173 @@ def traverse_graph(g, edge, delta, upstream=True): Edge ProperyMap (float) The signed change on the edges """ - # Property map for keeping track of the visited edge. Once an edge has - # been visited it won't be processed anymore. + plot = False amount = g.ep.amount - visited = g.new_edge_property("bool", val=False) change = g.new_edge_property("float", val=0.0) - change[edge] = delta - visited[edge] = True + total_change = g.new_edge_property("float", val=0.0) + + if plot: + # prepare plotting of intermediate results + from repair.apps.asmfa.tests import flowmodeltestdata + flowmodeltestdata.plot_materials(g, file='materials.png') + flowmodeltestdata.plot_amounts(g,'amounts.png', 'amount') + g.ep.change = change # We are only interested in the edges that define the solution g.set_edge_filter(g.ep.include) + MAX_ITERATIONS = 20 + balance_factor = g.vp.downstream_balance_factor.a + + # make a first run with the given changes to the implementation edge # By default we go upstream first, because 'demand dictates supply' if upstream: + node = edge.source() g.set_reversed(True) - balance_factor = 1 / g.vp.downstream_balance_factor.a - node = edge.target() + balance_factor = 1 / balance_factor else: + node = edge.target() g.set_reversed(False) - balance_factor = g.vp.downstream_balance_factor.a - node = edge.source() - node_visitor = NodeVisitor(g.vp["id"], amount, visited, change, + # initialize the node-visitors + node_visitor = NodeVisitor(g.vp["id"], amount, change, + balance_factor) + node_visitor2 = NodeVisitorBalanceDeltas(g.vp["id"], amount, change, balance_factor) + + node_visitor.forward = True + total_change.a[:] = 0 + new_delta = delta + i = 0 + change[edge] = new_delta + # start in one direction search.bfs_search(g, node, node_visitor) + change[edge] = new_delta - # now go downstream, if we started upstream - # (or upstream, if we started downstream) + if plot: + ## Plot changes after forward run + g.ep.change.a[:] = change.a + flowmodeltestdata.plot_amounts(g,'plastic_deltas.png', 'change') - g.set_reversed(not g.is_reversed()) - node = edge.target() if g.is_reversed() else edge.source() - # reverse the balancing factors - node_visitor.balance_factor = 1 / node_visitor.balance_factor - # print("\nTraversing in 2. direction") + node = reverse_graph(g, node_visitor, node_visitor2, edge) search.bfs_search(g, node, node_visitor) + change[edge] = new_delta + + if plot: + ## Plot changes after backward run + g.ep.change.a[:] = change.a + flowmodeltestdata.plot_amounts(g,'plastic_deltas.png', 'change') + + # balance out the changes + search.bfs_search(g, node, node_visitor2) + change[edge] = new_delta + + # add up the total changes + total_change.a += change.a + + if plot: + ## Plot total changes + g.ep.change.a[:] = total_change.a + flowmodeltestdata.plot_amounts(g,f'plastic_deltas_{i}.png', 'change') + + node = reverse_graph(g, node_visitor, node_visitor2, edge) + + if upstream: + if node.in_degree(): + sum_f = node.in_degree(weight=total_change) + new_delta = delta - sum_f + else: + new_delta = 0 + else: + if node.out_degree(): + sum_f = node.out_degree(weight=total_change) + new_delta = delta - sum_f + else: + new_delta = 0 + i += 1 + + + while i < MAX_ITERATIONS and abs(new_delta) > 0.00001: + change.a[:] = 0 + change[edge] = new_delta + + # start in one direction + + search.bfs_search(g, node, node_visitor) + change[edge] = 0 + + if plot: + ## Plot changes after forward run + g.ep.change.a[:] = change.a + flowmodeltestdata.plot_amounts(g,'plastic_deltas.png', 'change') + + + # now go downstream, if we started upstream + # (or upstream, if we started downstream) + node = reverse_graph(g, node_visitor, node_visitor2, edge) + if upstream: + sum_f = node.out_degree(weight=total_change) + \ + node.out_degree(weight=change) + else: + sum_f = node.in_degree(weight=total_change) + \ + node.in_degree(weight=change) + new_delta = delta - sum_f + change[edge] = new_delta + search.bfs_search(g, node, node_visitor) + + + if plot: + ## Plot changes after backward run + g.ep.change.a[:] = change.a + flowmodeltestdata.plot_amounts(g,'plastic_deltas.png', 'change') + + # balance out the changes + search.bfs_search(g, node, node_visitor2) + change[edge] = 0 + + if plot: + ## Plot changes after balancing + g.ep.change.a[:] = change.a + flowmodeltestdata.plot_amounts(g,'plastic_deltas.png', 'change') + + # add up the total changes + total_change.a += change.a + + node = reverse_graph(g, node_visitor, node_visitor2, edge) + + if plot: + ## Plot total changes + g.ep.change.a[:] = total_change.a + flowmodeltestdata.plot_amounts(g,f'plastic_deltas_{i}.png', 'change') + + if upstream: + if node.in_degree(): + sum_f = node.in_degree(weight=total_change) + new_delta = delta - sum_f + else: + new_delta = 0 + else: + if node.out_degree(): + sum_f = node.out_degree(weight=total_change) + new_delta = delta - sum_f + else: + new_delta = 0 + i += 1 # finally clean up - del visited g.set_reversed(False) g.clear_filters() - return node_visitor.change + return total_change + + +def reverse_graph(g, node_visitor: NodeVisitor, node_visitor2, edge): + g.set_reversed(not g.is_reversed()) + node_visitor.balance_factor = 1 / node_visitor.balance_factor + node = edge.target() if not g.is_reversed() else edge.source() + node_visitor.forward = not node_visitor.forward + node_visitor2.balance_factor = 1 / node_visitor2.balance_factor + return node class GraphWalker: diff --git a/repair/apps/asmfa/tests/flowmodeltestdata.py b/repair/apps/asmfa/tests/flowmodeltestdata.py index 127b6019f..9ce2c87ff 100644 --- a/repair/apps/asmfa/tests/flowmodeltestdata.py +++ b/repair/apps/asmfa/tests/flowmodeltestdata.py @@ -106,8 +106,7 @@ def plastic_package_graph(): G.vp.id[10] = 'Stock 2' G.vp.id[11] = 'Waste 2' flow = G.new_edge_property("object") - eid = G.new_edge_property( - "int") # need a persistent edge id, because graph-tool can reindex the edges + eid = G.new_edge_property("int") # need a persistent edge id, because graph-tool can reindex the edges G.edge_properties["flow"] = flow G.edge_properties["eid"] = eid e = G.add_edge(G.vertex(0), G.vertex(1)) @@ -153,12 +152,23 @@ def plastic_package_graph(): return split -def plot_amounts(g, file=None): - """Plots the graph with the 'amount' property on the edges into a file""" +def plot_amounts(g, file=None, prop='amount'): + """ + Plots the graph with the property defined by `prop` + on the edges into a file + + Parameters + ---------- + g : graph_tool.Graph + file : str + prop : str, optional (default='amount') + + """ + quantities = g.ep[prop] vertex_ids = [f'{int(v)}' for v in g.vertices()] vertex_text = g.new_vertex_property("string", vals=vertex_ids) mass_text = g.new_edge_property("string", - vals=[str(round(i, 2))for i in g.ep.amount]) + vals=[str(round(i, 3)) for i in quantities]) gt.draw.graph_draw(g, vertex_size=20, vertex_text=vertex_text, vprops={"text_position": -1, "font_size": 10}, diff --git a/repair/apps/asmfa/tests/test_graph.py b/repair/apps/asmfa/tests/test_graph.py index 6da888492..f83e29c22 100644 --- a/repair/apps/asmfa/tests/test_graph.py +++ b/repair/apps/asmfa/tests/test_graph.py @@ -75,7 +75,6 @@ def test_plastic_packaging(self): Consumption --> Recycling -0.12 Recycling --> Production -0.06 """ - return plastic = flowmodeltestdata.plastic_package_graph() gw = GraphWalker(plastic) change = gw.graph.new_edge_property('float') @@ -100,47 +99,86 @@ def test_plastic_packaging(self): else: gw.graph.ep.include[e] = False result = gw.calculate(implementation_edges, deltas) + + # for each flow, test if the results are approximately + # the expected value, taking some delta due to the balancing + # into account for i, e in enumerate(result.edges()): - print(f"{result.vp.id[e.source()]} --> {result.vp.id[e.target()]} / {result.ep.material[e]}: {result.ep.amount[e]}") + print(f"{result.vp.id[e.source()]} --> {result.vp.id[e.target()]} " + f"/ {result.ep.material[e]}: {result.ep.amount[e]}") if result.vp.id[e.source()] == 'Packaging' \ and result.vp.id[e.target()] == 'Consumption' \ and result.ep.material[e] == 'plastic': expected = 5.0 - 0.3 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Packaging->Consumption') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.2, + msg='Packaging->Consumption') elif result.vp.id[e.source()] == 'Oil rig' \ and result.vp.id[e.target()] == 'Oil refinery' \ and result.ep.material[e] == 'crude oil': expected = 20.0 - 0.3 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Oil rig->Oil refinery') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.2, + msg='Oil rig->Oil refinery') elif result.vp.id[e.source()] == 'Oil refinery' \ and result.vp.id[e.target()] == 'Production' \ and result.ep.material[e] == 'plastic': expected = 4.0 - 0.24 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Oil refinery->Production') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.2, + msg='Oil refinery->Production') elif result.vp.id[e.source()] == 'Production' \ and result.vp.id[e.target()] == 'Packaging' \ and result.ep.material[e] == 'plastic': expected = 5.0 - 0.3 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Production->Packaging') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.2, + msg='Production->Packaging') elif result.vp.id[e.source()] == 'Consumption' \ and result.vp.id[e.target()] == 'Burn' \ and result.ep.material[e] == 'plastic': expected = 3.0 - 0.18 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Consumption->Burn') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.1, + msg='Consumption->Burn') elif result.vp.id[e.source()] == 'Consumption' \ and result.vp.id[e.target()] == 'Recycling' \ and result.ep.material[e] == 'plastic': expected = 2.0 - 0.12 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Consumption->Recycling') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.1, + msg='Consumption->Recycling') elif result.vp.id[e.source()] == 'Recycling' \ and result.vp.id[e.target()] == 'Production' \ and result.ep.material[e] == 'plastic': expected = 1.0 - 0.06 - self.assertAlmostEqual(result.ep.amount[e], expected, 2, 'Recycling->Production') + self.assertAlmostEqual( + result.ep.amount[e], expected, delta=0.06, + msg='Recycling->Production') else: self.assertAlmostEqual(result.ep.amount[e], - gw.graph.ep.amount[e], places=2) - + gw.graph.ep.amount[e], + delta=result.ep.amount[e]/10) + + # test if the changes in all nodes are balanced + result.ep.change.a = result.ep.amount.a - gw.graph.ep.amount.a + + for u in result.vertices(): + # dangling nodes with no in- or outflows can be ignored + if not (u.in_degree() and u.out_degree()): + continue + # for the rest, the sum of the in-deltas should equal to the + # sum of the out-deltas, adjusted with the balancing factor + sum_in_deltas = u.in_degree(result.ep.change) + sum_out_deltas = u.out_degree(result.ep.change) + balanced_out_deltas = sum_out_deltas / bf[u] + balanced_delta = sum_in_deltas - balanced_out_deltas + self.assertAlmostEqual( + balanced_delta, 0, places=4, + msg=f'Node {int(u)} not balanced, deltas in: {sum_in_deltas}, ' + f'deltas out: {sum_out_deltas}, bf: {bf[u]}, ' + f'balanced deltas out: {balanced_out_deltas}' + ) def test_milk_production(self): """Reduce milk production between Farm->Packaging @@ -1251,6 +1289,7 @@ def affect_biofuel_chain(solpart): strat_digest_factor = strat_out_digester_sum / strat_in_digester_sum self.assertAlmostEqual(sq_digest_factor, strat_digest_factor, + places=1, msg=f'the factor at actor {biodigester} in ' 'strategy is not the same as in status quo') @@ -1281,7 +1320,7 @@ def assert_balance_factor(activity): sf_out = out_flows.aggregate(amount=Sum('strategy_amount'))['amount'] sq_factor = (sq_out / sq_in) if sq_out and sq_in else 1 sf_factor = (sf_out / sf_in) if sf_out and sf_in else 1 - self.assertAlmostEqual(sq_factor, sf_factor, + self.assertAlmostEqual(sq_factor, sf_factor, 1, msg='the balance factor at actor ' f'{actor} in strategy is not the ' 'same as in status quo') diff --git a/repair/apps/statusquo/views/computation.py b/repair/apps/statusquo/views/computation.py index 3f4a3e7ab..006898d3a 100644 --- a/repair/apps/statusquo/views/computation.py +++ b/repair/apps/statusquo/views/computation.py @@ -49,7 +49,8 @@ def get_queryset(self, indicator_flow, geom=None): hazardous = indicator_flow.hazardous.name avoidable = indicator_flow.avoidable.name - flows = get_annotated_fractionflows(self.keyflow_pk, strategy=self.strategy) + flows = get_annotated_fractionflows(self.keyflow_pk, + strategy_id=self.strategy.id) # filter flows by type (waste/product/both) if flow_type != 'BOTH': diff --git a/repair/js/views/common/baseview.js b/repair/js/views/common/baseview.js index 661242bc8..77a721dfb 100644 --- a/repair/js/views/common/baseview.js +++ b/repair/js/views/common/baseview.js @@ -50,8 +50,13 @@ var BaseView = Backbone.View.extend( }, /** format a number to currently set language **/ - format: function(value){ - return value.toLocaleString(this.language); + format: function(value, forceSignum){ + var formatted = value.toLocaleString(this.language); + if (this.forceSignum){ + if (value > 0) formatted = '+' + formatted; + if (value == 0) formatted = '+-0'; + } + return formatted; }, /** diff --git a/repair/js/views/common/filter-flows.js b/repair/js/views/common/filter-flows.js index 226d4d33e..e60ce998e 100644 --- a/repair/js/views/common/filter-flows.js +++ b/repair/js/views/common/filter-flows.js @@ -179,6 +179,7 @@ var FilterFlowsView = BaseView.extend( displayWarnings: true, filter: filter }); + this.flowsView.loader = this.loader; var displayLevel = this.displayLevelSelect.value; this.flowsView.draw(displayLevel); }, diff --git a/repair/js/views/common/flows.js b/repair/js/views/common/flows.js index 871814b48..02e276ad2 100644 --- a/repair/js/views/common/flows.js +++ b/repair/js/views/common/flows.js @@ -229,7 +229,6 @@ var FlowsView = BaseView.extend( apiTag: 'flows', apiIds: [ this.caseStudy.id, this.keyflowId] }); - this.loader.activate(); var data = {}; if (options.strategy) diff --git a/repair/js/views/common/flowsankey.js b/repair/js/views/common/flowsankey.js index bdf0eb74e..2ca0f4473 100644 --- a/repair/js/views/common/flowsankey.js +++ b/repair/js/views/common/flowsankey.js @@ -108,7 +108,8 @@ var FlowSankeyView = BaseView.extend( selectable: true, gradient: false, stretchFactor: (this.stretchInput) ? this.stretchInput.value: 1, - selectOnDoubleClick: (dblclkCheck) ? dblclkCheck.checked : false + selectOnDoubleClick: (dblclkCheck) ? dblclkCheck.checked : false, + forceSignum: this.forceSignum }) // redirect the event with same properties @@ -213,14 +214,12 @@ var FlowSankeyView = BaseView.extend( fractions.forEach(function(material){ var amount = (material.value != null) ? material.value: material.amount; if (amount == 0) return; - if (_this.forceSignum && amount >= 0) - text += '+'; if (_this.showRelativeComposition){ fraction = amount / totalAmount, value = Math.round(fraction * 100000) / 1000; - text += _this.format(value) + '%'; + text += _this.format(value, _this.forceSignum) + '%'; } else { - text += _this.format(amount) + ' ' + gettext('t/year'); + text += _this.format(amount, _this.forceSignum) + ' ' + gettext('t/year'); } text += ' ' + material.name if (material.avoidable) text += ' ' + gettext('avoidable') +''; @@ -263,9 +262,6 @@ var FlowSankeyView = BaseView.extend( var crepr = compositionRepr(flow), amount = flow.get('amount'), value = (norm === 'log')? normalize(amount): Math.round(amount); - - if (_this.forceSignum && amount >= 0) - amount = '+' + amount.toLocaleString(this.language); links.push({ id: flow.id, originalData: flow, @@ -323,7 +319,7 @@ var FlowSankeyView = BaseView.extend( var header = [gettext('origin'), gettext('origin') + '_code', gettext('origin') + '_wkt', gettext('destination'), gettext('destination') + '_code', gettext('destination') + '_wkt', - gettext('amount'), gettext('composition')], + gettext('amount (t/year)'), gettext('composition')], rows = [], _this = this; rows.push(header.join('\t')); @@ -340,11 +336,9 @@ var FlowSankeyView = BaseView.extend( destination = link.target, originName = origin.name, destinationName = (!link.isStock) ? destination.name : gettext('Stock'), - amount = _this.format(link.amount) + ' ' + link.units, + amount = _this.format(link.amount, _this.forceSignum), composition = link.composition; - if (_this.forceSignum && amount >= 0) - amount = '-' + amount; var originCode = origin.code, destinationCode = (destination) ? destination.code: '', originWkt = '', diff --git a/repair/js/views/data-entry/actors-flows.js b/repair/js/views/data-entry/actors-flows.js index 75479a64a..176ad3eed 100644 --- a/repair/js/views/data-entry/actors-flows.js +++ b/repair/js/views/data-entry/actors-flows.js @@ -191,6 +191,9 @@ var FlowsEditView = BaseView.extend( plugins: ["wholerow", "ui", "types", "themes"] }); $(this.dataTree).on("select_node.jstree", this.nodeSelected); + $(this.dataTree).bind("hover_node.jstree", function (e, data) { + $("#" + data.node.id).prop('title', data.node.text); + }) this.filterSelect = this.el.querySelector('#included-filter-select'); this.actorsTable = $('#actors-table').DataTable(); $('#actors-table tbody').on('click', 'tr', function () { diff --git a/repair/js/views/status-quo/workshop-flows.js b/repair/js/views/status-quo/workshop-flows.js index c882333d8..65ac7158c 100644 --- a/repair/js/views/status-quo/workshop-flows.js +++ b/repair/js/views/status-quo/workshop-flows.js @@ -95,6 +95,7 @@ var FlowsWorkshopView = BaseView.extend( materials: this.materials, filter: filter }); + this.flowsView.loader = this.loader; this.draw(); }, diff --git a/repair/js/views/strategy/modified-flows.js b/repair/js/views/strategy/modified-flows.js index 4c273ce5a..7c1393625 100644 --- a/repair/js/views/strategy/modified-flows.js +++ b/repair/js/views/strategy/modified-flows.js @@ -118,6 +118,7 @@ var ModifiedFlowsView = BaseView.extend( filter: filter, strategy: this.strategy }); + this.flowsView.loader = this.loader; this.draw(); }, diff --git a/repair/js/visualizations/sankey.js b/repair/js/visualizations/sankey.js index b60fdd332..ab52f7236 100644 --- a/repair/js/visualizations/sankey.js +++ b/repair/js/visualizations/sankey.js @@ -39,12 +39,18 @@ class Sankey{ .size([this.width * this.stretchFactor, this.height]) .align(alignment); this.selectable = options.selectable; + this.forceSignum = options.forceSignum; this.gradient = options.gradient; this.selectOnDoubleClick = options.selectOnDoubleClick || false; } format(d) { - return d.toLocaleString(this.language); + var formatted = d.toLocaleString(this.language); + if (this.forceSignum){ + if (d > 0) formatted = '+' + formatted; + if (d == 0) formatted = '+-0'; + } + return formatted; } align(alignment){ @@ -175,6 +181,7 @@ class Sankey{ outSum += parseInt(link.amount || link.value); if (!outUnits) outUnits = link.units; } + var ins = "in: " + _this.format(inSum) + " " + (inUnits || ""), out = "out: " + _this.format(outSum) + " " + (outUnits || ""); var text = (d.text) ? d.text + '
': ''; diff --git a/repair/templates/common.html b/repair/templates/common.html index aacd463c0..8db4099fa 100644 --- a/repair/templates/common.html +++ b/repair/templates/common.html @@ -4,8 +4,8 @@ - - + + diff --git a/repair/templates/conclusions/workshop.html b/repair/templates/conclusions/workshop.html index f552a764b..a67b5f268 100644 --- a/repair/templates/conclusions/workshop.html +++ b/repair/templates/conclusions/workshop.html @@ -63,7 +63,7 @@