Coverage for bim2sim/elements/aggregation/hvac_aggregations.py: 77%

583 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 17:09 +0000

1import math 

2from functools import partial 

3from typing import List, Iterable, Dict, Union, Tuple, Optional 

4 

5import networkx as nx 

6import numpy as np 

7 

8from bim2sim.elements import hvac_elements as hvac 

9from bim2sim.elements.aggregation import AggregationMixin, logger 

10from bim2sim.elements.base_elements import ProductBased 

11from bim2sim.elements.graphs.hvac_graph import HvacGraph 

12from bim2sim.elements.hvac_elements import HVACPort 

13from bim2sim.elements.mapping import attribute 

14from bim2sim.elements.mapping.units import ureg 

15from bim2sim.kernel.decision import BoolDecision, DecisionBunch 

16 

17 

18def verify_edge_ports(func): 

19 """Decorator to verify edge ports""" 

20 

21 def wrapper(agg_instance, *args, **kwargs): 

22 ports = func(agg_instance, *args, **kwargs) 

23 for port in ports: 

24 if not port.connection: 

25 continue 

26 if port.connection.parent in agg_instance.elements: 

27 raise AssertionError("%s (%s) is not an edge port of %s" % ( 

28 port, port.guid, agg_instance)) 

29 return ports 

30 

31 return wrapper 

32 

33 

34class HVACAggregationPort(HVACPort): 

35 """Port for Aggregation""" 

36 guid_prefix = 'AggPort' 

37 

38 def __init__(self, originals, *args, **kwargs): 

39 super().__init__(*args, **kwargs) 

40 # TODO / TBD: DJA: can one Port replace multiple? what about position? 

41 

42 if not type(originals) == list: 

43 originals = [originals] 

44 if not all(isinstance(n, hvac.HVACPort) for n in originals): 

45 raise TypeError("originals must by HVACPorts") 

46 self.originals = originals 

47 self.flow_direction = self.flow_direction_from_original() 

48 

49 def flow_direction_from_original(self): 

50 if len(self.originals) > 1: 

51 flow_directions = set( 

52 [original.flow_direction for original in self.originals]) 

53 if len(flow_directions) > 1: 

54 raise NotImplementedError( 

55 'Aggregation of HVACPorts with different flow directions' 

56 'is not implemented.') 

57 else: 

58 return list(flow_directions)[0] 

59 else: 

60 originals = self.originals[0] 

61 while originals: 

62 if hasattr(originals, 'originals'): 

63 if len(originals.originals) > 1: 

64 raise NotImplementedError( 

65 'Aggregation with more than one original is not ' 

66 'implemented.') 

67 originals = originals.originals[0] 

68 else: 

69 return originals.flow_direction 

70 

71 def _calc_position(self, name): 

72 """Position of original port""" 

73 return self.originals.position 

74 

75 

76class HVACAggregationMixin(AggregationMixin): 

77 """ Mixin class for all HVACAggregations. 

78 

79 Adds some HVAC specific functionality to the AggregationMixin. 

80 

81 Args: 

82 base_graph: networkx graph that should be searched for aggregations 

83 match_graph: networkx graph that only holds matches 

84 """ 

85 

86 def __init__(self, base_graph: nx.Graph, match_graph: nx.Graph, *args, 

87 **kwargs): 

88 # make get_ports signature match_graph ProductBased.get_ports 

89 self.get_ports = partial(self.get_ports, base_graph, match_graph) 

90 graph_elements = list(set([node.parent for node in match_graph.nodes])) 

91 super().__init__(graph_elements, *args, **kwargs) 

92 

93 @verify_edge_ports 

94 def get_ports(self, base_graph: HvacGraph, match_graph: HvacGraph 

95 ) -> List[HVACPort]: 

96 """ Get the edge ports based on the difference between base_graph and 

97 match_graph, 

98 

99 Args: 

100 base_graph: The base graph. 

101 match_graph: The matching graph. 

102 

103 Returns: 

104 A list of HVACPort objects representing the edge ports. 

105 """ 

106 # edges of g excluding all relations to s 

107 e1 = base_graph.subgraph(base_graph.nodes - match_graph.nodes).edges 

108 

109 # if graph and match_graph are identical 

110 if not e1: 

111 # ports with only one connection are edge ports in this case 

112 edge_ports = [v for v, d in match_graph.degree() if d == 1] 

113 else: 

114 # all edges related to s 

115 e2 = base_graph.edges - e1 

116 # related to s but not s exclusive 

117 e3 = e2 - match_graph.edges 

118 # get only edge_ports that belong to the match_graph graph 

119 edge_ports = list( 

120 set([port for port in [e for x in list(e3) for e in x] 

121 if port in match_graph])) 

122 ports = [HVACAggregationPort(port, parent=self) for port in edge_ports] 

123 return ports 

124 

125 @classmethod 

126 def get_empty_mapping(cls, elements: Iterable[ProductBased]): 

127 """ Get information to remove elements. 

128 

129 Args: 

130 elements: 

131 

132 Returns: 

133 mapping: tuple of mapping dict with original ports as values and 

134 None as keys. 

135 connections: connection list of outer connections. 

136 """ 

137 ports = [port for element in elements for port in element.ports] 

138 mapping = {port: None for port in ports} 

139 # TODO: len > 1, optimize 

140 external_ports = [] 

141 for port in ports: 

142 if port.connection and port.connection.parent not in elements: 

143 external_ports.append(port.connection) 

144 

145 mapping[external_ports[0].connection] = external_ports[1] 

146 mapping[external_ports[1].connection] = external_ports[0] 

147 connections = [] 

148 

149 return mapping, connections 

150 

151 def get_replacement_mapping(self) \ 

152 -> Dict[HVACPort, Union[HVACAggregationPort, None]]: 

153 """ Get replacement dict for existing ports.""" 

154 mapping = {port: None for element in self.elements 

155 for port in element.ports} 

156 for port in self.ports: 

157 for original in port.originals: 

158 mapping[original] = port 

159 return mapping 

160 

161 @classmethod 

162 def find_matches(cls, base_graph: HvacGraph 

163 ) -> Tuple[List[nx.Graph], List[dict]]: 

164 """ Find all matches for aggregation in HVAC graph. 

165 

166 Args: 

167 base_graph: The HVAC graph that is searched for potential 

168 matches. 

169 

170 Returns: 

171 matches_graphs: List of HVAC graphs that matches the aggregation. 

172 metas: List of dict with metas information. One element for each 

173 matches_graphs. 

174 

175 Raises: 

176 NotImplementedError: If method is not implemented. 

177 """ 

178 raise NotImplementedError( 

179 "Method %s.find_matches not implemented" % cls.__name__) 

180 

181 def _calc_has_pump(self, name) -> bool: 

182 """ Determines if aggregation contains pumps. 

183 

184 Returns: 

185 True, if aggregation has pumps 

186 """ 

187 has_pump = False 

188 for ele in self.elements: 

189 if hvac.Pump is ele.__class__: 

190 has_pump = True 

191 break 

192 return has_pump 

193 

194 

195class PipeStrand(HVACAggregationMixin, hvac.Pipe): 

196 """ Aggregates pipe strands, i.e. pipes, pipe fittings and valves. 

197 

198 This aggregation reduces the number of elements by merging straight 

199 connected elements with just two ports into one PipeStrand. The length and 

200 a medium diameter are calculated based on the aggregated elements to 

201 maintain meaningful parameters for pressure loss calculations. 

202 """ 

203 aggregatable_classes = {hvac.Pipe, hvac.PipeFitting, hvac.Valve} 

204 multi = ('length', 'diameter') 

205 

206 @classmethod 

207 def find_matches(cls, base_graph: HvacGraph 

208 ) -> Tuple[List[HvacGraph], List[dict]]: 

209 """ Find all matches for PipeStrand in HvacGraph. 

210 

211 Args: 

212 base_graph: The Hvac graph to search for matches in. 

213 

214 Returns: 

215 A tuple containing two lists: 

216 - matches_graphs: List of HvacGraphs that hold PipeStrands 

217 - metas: List of dict with meta information. One element for 

218 each match. 

219 """ 

220 pipe_strands = HvacGraph.get_type_chains( 

221 base_graph.element_graph, cls.aggregatable_classes, 

222 include_singles=True) 

223 matches_graphs = [base_graph.subgraph_from_elements(pipe_strand) 

224 for pipe_strand in pipe_strands] 

225 

226 metas = [{} for x in matches_graphs] # no metadata calculated 

227 return matches_graphs, metas 

228 

229 @attribute.multi_calc 

230 def _calc_avg(self) -> dict: 

231 """ Calculates the total length and average diameter of all pipe-like 

232 elements. 

233 """ 

234 

235 total_length = 0 

236 avg_diameter = 0 

237 diameter_times_length = 0 

238 

239 for pipe in self.elements: 

240 length = getattr(pipe, "length") 

241 diameter = getattr(pipe, "diameter") 

242 if not (length and diameter): 

243 logger.warning("Ignored '%s' in aggregation", pipe) 

244 continue 

245 

246 diameter_times_length += diameter * length 

247 total_length += length 

248 

249 if total_length != 0: 

250 avg_diameter = diameter_times_length / total_length 

251 

252 result = dict( 

253 length=total_length, 

254 diameter=avg_diameter 

255 ) 

256 return result 

257 

258 diameter = attribute.Attribute( 

259 description="Average diameter of aggregated pipe", 

260 functions=[_calc_avg], 

261 unit=ureg.millimeter, 

262 dependant_elements='elements' 

263 ) 

264 

265 length = attribute.Attribute( 

266 description="Length of aggregated pipe", 

267 functions=[_calc_avg], 

268 unit=ureg.meter, 

269 dependant_elements='elements' 

270 ) 

271 

272 

273class UnderfloorHeating(PipeStrand): 

274 """ Class for aggregating underfloor heating systems. 

275 

276 The normal pitch (spacing) between pipes is typically between 0.1m and 0.2m. 

277 """ 

278 

279 @classmethod 

280 def find_matches(cls, base_graph: HvacGraph 

281 ) -> Tuple[List[HvacGraph], List[dict]]: 

282 """ Finds matches of underfloor heating systems in a given graph. 

283 

284 Args: 

285 base_graph: An HvacGraph that should be checked for underfloor 

286 heating systems. 

287 

288 Returns: 

289 A tuple containing two lists: 

290 - matches_graphs: A list of HvacGraphs that contain underfloor 

291 heating systems. 

292 - metas: A list of dict with meta information for each 

293 underfloor heating system. One element for each match. 

294 """ 

295 chains = HvacGraph.get_type_chains( 

296 base_graph.element_graph, cls.aggregatable_classes, 

297 include_singles=True) 

298 matches_graphs = [] 

299 metas = [] 

300 for chain in chains: 

301 meta = cls.check_conditions(chain) 

302 if meta: 

303 metas.append(meta) 

304 matches_graphs.append(base_graph.subgraph_from_elements(chain)) 

305 return matches_graphs, metas 

306 

307 @staticmethod 

308 def check_number_of_elements(chain: nx.classes.reportviews.NodeView, 

309 tolerance: int = 20) -> bool: 

310 """ Check if the targeted chain has more than 20 elements. 

311 

312 This method checks if a given chain has more than the specified number 

313 of elements. 

314 

315 Args: 

316 chain: Possible chain of consecutive elements to be an underfloor 

317 heating. 

318 tolerance: Integer tolerance value to check the number of elements. 

319 Default is 20. 

320 

321 Returns: 

322 True if the chain has more than the specified number of elements, 

323 False otherwise. 

324 """ 

325 return len(chain) >= tolerance 

326 

327 @staticmethod 

328 def check_pipe_strand_horizontality(ports_coors: np.ndarray, 

329 tolerance: float = 0.8) -> bool: 

330 """ Checks the horizontality of a pipe strand. 

331 

332 This method checks if the pipe strand is located horizontally, meaning 

333 it is parallel to the floor and most elements are in the same z plane. 

334 

335 Args: 

336 ports_coors: An array with pipe strand port coordinates. 

337 tolerance: Tolerance to check pipe strand horizontality. 

338 Default is 0.8. 

339 

340 Returns: 

341 True, if check succeeds and False if check fails. 

342 """ 

343 counts = np.unique(ports_coors[:, 2], return_counts=True) 

344 # TODO: cluster z coordinates 

345 idx_max = np.argmax(counts[1]) 

346 return counts[1][idx_max] / ports_coors.shape[0] >= tolerance 

347 

348 @staticmethod 

349 def get_pipe_strand_attributes(ports_coors: np.ndarray, 

350 chain: nx.classes.reportviews.NodeView 

351 ) -> [*(ureg.Quantity,) * 5]: 

352 """ Gets the attributes of a pipe strand. 

353 

354 This method retrieves the attributes of a pipe strand in order to 

355 perform further checks and calculations. 

356 

357 Args: 

358 ports_coors: An array with pipe strand port coordinates. 

359 chain: A possible chain of elements to be an Underfloor heating. 

360 

361 Returns: 

362 heating_area: Underfloor heating area. 

363 total_length: Underfloor heating total pipe length. 

364 avg_diameter: Average underfloor heating diameter. 

365 dist_x: Underfloor heating dimension in x. 

366 dist_y: Underfloor heating dimension in y. 

367 """ 

368 total_length = sum(segment.length for segment in chain if 

369 segment.length is not None) 

370 avg_diameter = (sum(segment.diameter ** 2 * segment.length for segment 

371 in chain if segment.length is not 

372 None) / total_length) ** 0.5 

373 length_unit = total_length.u 

374 x_coord, y_coord = ports_coors[:, 0], ports_coors[:, 1] 

375 min_x = ports_coors[np.argmin(x_coord)][:2] 

376 max_x = ports_coors[np.argmax(x_coord)][:2] 

377 min_y = ports_coors[np.argmin(y_coord)][:2] 

378 max_y = ports_coors[np.argmax(y_coord)][:2] 

379 if min_x[1] == max_x[1] or min_y[0] == max_y[0]: 

380 dist_x = (max_x[0] - min_x[0]) * length_unit 

381 dist_y = (max_y[1] - min_y[1]) * length_unit 

382 heating_area = (dist_x * dist_y) 

383 else: 

384 dist_x = (np.linalg.norm(min_y - max_x)) * length_unit 

385 dist_y = (np.linalg.norm(min_y - min_x)) * length_unit 

386 heating_area = (dist_x * dist_y) 

387 

388 return heating_area, total_length, avg_diameter, dist_x, dist_y 

389 

390 @staticmethod 

391 def get_ufh_type(): 

392 # TODO: function to obtain the underfloor heating form based on issue 

393 # #211 

394 raise NotImplementedError 

395 

396 @classmethod 

397 def get_pipe_strand_spacing(cls, 

398 chain: nx.classes.reportviews.NodeView, 

399 dist_x: ureg.Quantity, 

400 dist_y: ureg.Quantity, 

401 tolerance: int = 10 

402 ) -> [*(ureg.Quantity,) * 2]: 

403 """ Sorts the pipe elements according to their angle in the horizontal 

404 plane. Necessary to calculate subsequently the underfloor heating 

405 spacing. 

406 

407 Args: 

408 chain: possible chain of elements to be an Underfloor heating 

409 dist_x: Underfloor heating dimension in x 

410 dist_y: Underfloor heating dimension in y 

411 tolerance: integer tolerance to get pipe strand spacing 

412 

413 Returns: 

414 x_spacing: Underfloor heating pitch in x, 

415 y_spacing: Underfloor heating pitch in y 

416 """ 

417 # ToDo: what if multiple pipe elements on the same line? Collinear 

418 # algorithm, issue #211 

419 orientations = {} 

420 for element in chain: 

421 if type(element) is hvac.Pipe: 

422 a = abs(element.ports[0].position[1] - 

423 element.ports[1].position[1]) 

424 b = abs(element.ports[0].position[0] - 

425 element.ports[1].position[0]) 

426 if b != 0: 

427 theta = int(math.degrees(math.atan(a / b))) 

428 else: 

429 theta = 90 

430 if theta not in orientations: 

431 orientations[theta] = [] 

432 orientations[theta].append(element) 

433 for orient in orientations.copy(): 

434 if len(orientations[orient]) < tolerance: 

435 del orientations[orient] 

436 orientations = list(sorted(orientations.items())) 

437 x_spacing = dist_x / (len(orientations[0][1]) - 1) 

438 y_spacing = dist_y / (len(orientations[1][1]) - 1) 

439 return x_spacing, y_spacing 

440 

441 @staticmethod 

442 def check_heating_area(heating_area: ureg.Quantity, 

443 tolerance: ureg.Quantity = 

444 1e6 * ureg.millimeter ** 2) -> bool: 

445 """ Check if the total area of the underfloor heating is greater than 

446 the tolerance value - just as safety factor. 

447 

448 Args: 

449 heating_area: Underfloor heating area, 

450 tolerance: Quantity tolerance to check heating area 

451 

452 Returns: 

453 None: if check fails 

454 True: if check succeeds 

455 """ 

456 return heating_area >= tolerance 

457 

458 @staticmethod 

459 def check_spacing(x_spacing: ureg.Quantity, 

460 y_spacing: ureg.Quantity, 

461 tolerance: tuple = (90 * ureg.millimeter, 

462 210 * ureg.millimeter) 

463 ) -> Optional[bool]: 

464 """ Check if the spacing between adjacent elements with the same 

465 orientation is between the tolerance values. 

466 Args: 

467 x_spacing: Underfloor heating pitch in x 

468 y_spacing: Underfloor heating pitch in y 

469 tolerance: tuple tolerance to check underfloor heating spacing 

470 

471 Returns: 

472 None: if check fails 

473 True: if check succeeds 

474 """ 

475 if not ((tolerance[0] < x_spacing < tolerance[1]) 

476 or (tolerance[0] < y_spacing < tolerance[1])): 

477 return 

478 return True 

479 

480 @staticmethod 

481 def check_kpi(total_length: ureg.Quantity, 

482 avg_diameter: ureg.Quantity, 

483 heating_area: ureg.Quantity, 

484 tolerance: tuple = (0.09, 0.01)) -> Optional[bool]: 

485 """ Check if the quotient between the cross-sectional area of the pipe 

486 strand (x-y plane) and the total heating area is between the 

487 tolerance values - area density for underfloor heating. 

488 

489 Args: 

490 total_length: Underfloor heating total pipe length, 

491 avg_diameter: Average Underfloor heating diameter, 

492 heating_area: Underfloor heating area, 

493 tolerance: tuple tolerance to check underfloor heating kpi 

494 

495 Returns: 

496 None: if check fails 

497 True: if check succeeds 

498 """ 

499 kpi_criteria = (total_length * avg_diameter) / heating_area 

500 return tolerance[0] > kpi_criteria > tolerance[1] 

501 

502 @classmethod 

503 def check_conditions(cls, chain: nx.classes.reportviews.NodeView 

504 ) -> Optional[dict]: 

505 """ Checks ps_elements and returns instance of UnderfloorHeating if all 

506 following criteria are fulfilled: 

507 0. minimum of 20 elements 

508 1. the pipe strand is located horizontally 

509 2. the pipe strand elements located in an specific z-coordinate 

510 3. the spacing tolerance 

511 4. underfloor heating area tolerance 

512 5. kpi criteria 

513 

514 Args: 

515 chain: possible chain of elements to be an Underfloor heating 

516 

517 Returns: 

518 None: if check fails 

519 meta: dict with calculated values if check succeeds 

520 """ 

521 # TODO: use only floor heating pipes and not connecting pipes 

522 if not cls.check_number_of_elements(chain): 

523 return 

524 ports_coordinates = np.array( 

525 [p.position for e in chain for p in e.ports]) 

526 if not cls.check_pipe_strand_horizontality(ports_coordinates): 

527 return 

528 

529 heating_area, total_length, avg_diameter, dist_x, dist_y = \ 

530 cls.get_pipe_strand_attributes(ports_coordinates, chain) 

531 x_spacing, y_spacing = cls.get_pipe_strand_spacing( 

532 chain, dist_x, dist_y) 

533 

534 if not cls.check_heating_area(heating_area): 

535 return 

536 if not cls.check_spacing(x_spacing, y_spacing): 

537 return 

538 if not cls.check_kpi(total_length, avg_diameter, heating_area): 

539 return 

540 

541 meta = dict( 

542 length=total_length, 

543 diameter=avg_diameter, 

544 heating_area=heating_area, 

545 x_spacing=x_spacing, 

546 y_spacing=y_spacing 

547 ) 

548 return meta 

549 

550 def is_consumer(self): 

551 return True 

552 

553 heating_area = attribute.Attribute( 

554 unit=ureg.meter ** 2, 

555 description='Heating area', 

556 ) 

557 x_spacing = attribute.Attribute( 

558 unit=ureg.meter, 

559 description='Spacing in x', 

560 ) 

561 y_spacing = attribute.Attribute( 

562 unit=ureg.meter, 

563 description='Spacing in y', 

564 ) 

565 rated_power = attribute.Attribute( 

566 unit=ureg.kilowatt, 

567 description="rated power" 

568 ) 

569 rated_mass_flow = attribute.Attribute( 

570 description="Rated mass flow of pump", 

571 unit=ureg.kg / ureg.s, 

572 ) 

573 

574 

575class ParallelPump(HVACAggregationMixin, hvac.Pump): 

576 """ Aggregates pumps in parallel.""" 

577 aggregatable_classes = {hvac.Pump, hvac.Pipe, hvac.PipeFitting, PipeStrand} 

578 whitelist_classe = {hvac.Pump} 

579 

580 multi = ('rated_power', 'rated_height', 'rated_volume_flow', 'diameter', 

581 'diameter_strand', 'length') 

582 

583 @classmethod 

584 def find_matches(cls, base_graph: HvacGraph 

585 ) -> Tuple[List[HvacGraph], List[dict]]: 

586 """ Find matches of parallel pumps in the given graph. 

587 

588 Args: 

589 base_graph: HVAC graph that should be checked for parallel pumps. 

590 

591 Returns: 

592 A tuple containing two lists: 

593 matches_graph: List of HVAC graphs that hold the parallel pumps. 

594 metas: List of dict with meta information. One element for 

595 each match. 

596 """ 

597 element_graph = base_graph.element_graph 

598 inert_classes = cls.aggregatable_classes - cls.whitelist_classe 

599 parallel_pump_strands = HvacGraph.get_parallels( 

600 element_graph, cls.whitelist_classe, inert_classes, 

601 grouping={'rated_power': 'equal'}, 

602 grp_threshold=1) 

603 matches_graph = [base_graph.subgraph_from_elements(parallel.nodes) 

604 for parallel in parallel_pump_strands] 

605 metas = [{} for x in matches_graph] # no metadata calculated 

606 return matches_graph, metas 

607 

608 

609 @attribute.multi_calc 

610 def _calc_avg(self) -> dict: 

611 """Calculates the total length and average diameter of all pump-like 

612 elements.""" 

613 avg_diameter_strand = 0 

614 total_length = 0 

615 diameter_times_length = 0 

616 

617 for item in self.not_pump_elements: 

618 if hasattr(item, "diameter") and hasattr(item, "length"): 

619 length = item.length 

620 diameter = item.diameter 

621 if not (length and diameter): 

622 logger.info("Ignored '%s' in aggregation", item) 

623 continue 

624 

625 diameter_times_length += length * diameter 

626 total_length += length 

627 else: 

628 logger.info("Ignored '%s' in aggregation", item) 

629 

630 if total_length != 0: 

631 avg_diameter_strand = diameter_times_length / total_length 

632 

633 result = dict( 

634 length=total_length, 

635 diameter_strand=avg_diameter_strand 

636 ) 

637 return result 

638 

639 def get_replacement_mapping(self) \ 

640 -> Dict[HVACPort, Union[HVACAggregationPort, None]]: 

641 mapping = super().get_replacement_mapping() 

642 

643 # TODO: cant this be solved in find_matches? 

644 # search for aggregations made during the parallel pump construction 

645 new_aggregations = [element.aggregation for element in self.elements if 

646 element.aggregation is not self] 

647 for port in (p for a in new_aggregations for p in a.ports): 

648 for original in port.originals: 

649 mapping[original] = port 

650 return mapping 

651 

652 @property 

653 def pump_elements(self) -> list: 

654 """list of pump-like elements present on the aggregation""" 

655 return [ele for ele in self.elements if isinstance(ele, hvac.Pump)] 

656 

657 def _calc_rated_power(self, name) -> ureg.Quantity: 

658 """Calculate the rated power adding the rated power of the pump-like 

659 elements""" 

660 if all(ele.rated_power for ele in self.pump_elements): 

661 return sum([ele.rated_power for ele in self.pump_elements]) 

662 else: 

663 return None 

664 

665 rated_power = attribute.Attribute( 

666 unit=ureg.kilowatt, 

667 description="rated power", 

668 functions=[_calc_rated_power], 

669 dependant_elements='pump_elements' 

670 ) 

671 

672 def _calc_rated_height(self, name) -> ureg.Quantity: 

673 """Calculate the rated height, using the maximal rated height of 

674 the pump-like elements""" 

675 return max([ele.rated_height for ele in self.pump_elements]) 

676 

677 rated_height = attribute.Attribute( 

678 description='rated height', 

679 functions=[_calc_rated_height], 

680 unit=ureg.meter, 

681 dependant_elements='pump_elements' 

682 ) 

683 

684 def _calc_volume_flow(self, name) -> ureg.Quantity: 

685 """Calculate the volume flow, adding the volume flow of the pump-like 

686 elements""" 

687 return sum([ele.rated_volume_flow for ele in self.pump_elements]) 

688 

689 rated_volume_flow = attribute.Attribute( 

690 description='rated volume flow', 

691 functions=[_calc_volume_flow], 

692 unit=ureg.meter ** 3 / ureg.hour, 

693 dependant_elements='pump_elements' 

694 ) 

695 

696 def _calc_diameter(self, name) -> ureg.Quantity: 

697 """Calculate the diameter, using the pump-like elements diameter""" 

698 return sum(item.diameter ** 2 for item in self.pump_elements) ** 0.5 

699 

700 diameter = attribute.Attribute( 

701 description='diameter', 

702 functions=[_calc_diameter], 

703 unit=ureg.millimeter, 

704 dependant_elements='pump_elements' 

705 ) 

706 

707 @property 

708 def not_pump_elements(self) -> list: 

709 """list of not-pump-like elements present on the aggregation""" 

710 return [ele for ele in self.elements if not isinstance(ele, hvac.Pump)] 

711 

712 length = attribute.Attribute( 

713 description='length of aggregated pipe elements', 

714 functions=[_calc_avg], 

715 unit=ureg.meter, 

716 dependant_elements='not_pump_elements' 

717 ) 

718 

719 diameter_strand = attribute.Attribute( 

720 description='average diameter of aggregated pipe elements', 

721 functions=[_calc_avg], 

722 unit=ureg.millimeter, 

723 dependant_elements='not_pump_elements' 

724 ) 

725 

726 

727class Consumer(HVACAggregationMixin, hvac.HVACProduct): 

728 """ A class that aggregates a Consumer. 

729 

730 This class represents a Consumer system in an HVAC graph, which can 

731 contain various elements such as space heaters, pipes, pumps, and 

732 valves. It aggregates these elements into a single entity, called 

733 Consumer. 

734 

735 Attributes: 

736 multi: A tuple of attribute names that can have multiple 

737 values for a Consumer. 

738 aggregatable_classes: A dict of element classes that can be 

739 aggregated into a Consumer. 

740 whitelist_classes: A dict of element classes that should be included 

741 when searching for a Consumer in an HVAC graph. 

742 blacklist_classes: A dict of element classes that should be excluded 

743 when searching for a Consumer in an HVAC graph. 

744 boarder_classes: A dictionary of element classes that define the 

745 border of a Consumer system. 

746 """ 

747 

748 aggregatable_classes = { 

749 hvac.SpaceHeater, hvac.Pipe, hvac.PipeFitting, hvac.Junction, 

750 hvac.Pump, hvac.Valve, hvac.ThreeWayValve, PipeStrand, 

751 UnderfloorHeating} 

752 whitelist_classes = {hvac.SpaceHeater, UnderfloorHeating} 

753 blacklist_classes = {hvac.Chiller, hvac.Boiler, hvac.CoolingTower, 

754 hvac.HeatPump, hvac.Storage, hvac.CHP} 

755 boarder_classes = {hvac.Distributor} 

756 multi = ('has_pump', 'rated_power', 'rated_pump_power', 'rated_height', 

757 'rated_volume_flow', 'temperature_inlet', 

758 'temperature_outlet', 'volume', 'description') 

759 

760 @classmethod 

761 def find_matches(cls, base_graph: HvacGraph 

762 ) -> Tuple[List[HvacGraph], List[dict]]: 

763 """ Find matches of consumer in the given base HVAC graph. 

764 

765 Args: 

766 base_graph: The HVAC graph to search for consumers in. 

767 

768 Returns: 

769 A tuple with two lists 

770 - matches_graph: A list of HVAC graphs that contain 

771 consumers, and the second list contains 

772 - metas: A list of dict with meta information about each 

773 consumer 

774 """ 

775 # remove boarder_classes nodes from base_graph to separate cycles 

776 graph = HvacGraph.remove_classes_from(base_graph, cls.boarder_classes) 

777 cycles = nx.connected_components(graph) 

778 

779 matches_graphs = [] 

780 for cycle in cycles: 

781 cycle_graph = graph.subgraph(cycle) 

782 # check for blacklist_classes in cycle, i.e. generators 

783 generator = {ele for ele in cycle_graph.elements if 

784 ele.__class__ in cls.blacklist_classes} 

785 if generator: 

786 # check for whitelist_classes in cycle, i.e. consumers 

787 gen_con = {ele for ele in cycle_graph.elements if 

788 ele.__class__ in cls.whitelist_classes} 

789 if gen_con: 

790 # TODO: Consumer separieren 

791 pass 

792 else: 

793 consumer = {ele for ele in cycle_graph.elements if 

794 ele.__class__ in cls.whitelist_classes} 

795 if consumer: 

796 matches_graphs.append(cycle_graph) 

797 

798 metas = [{} for x in matches_graphs] 

799 return matches_graphs, metas 

800 

801 @property 

802 def pump_elements(self) -> list: 

803 """ List of pump-like elements present on the aggregation.""" 

804 return [ele for ele in self.elements if isinstance(ele, hvac.Pump)] 

805 

806 @property 

807 def not_pump_elements(self) -> list: 

808 """ List of not-pump-like elements present on the aggregation.""" 

809 return [ele for ele in self.elements if not isinstance(ele, hvac.Pump)] 

810 

811 def _calc_TControl(self, name): 

812 return any([isinstance(ele, hvac.ThreeWayValve) for ele in self.elements]) 

813 

814 @property 

815 def whitelist_elements(self) -> list: 

816 """ List of whitelist_classes elements present on the aggregation.""" 

817 return [ele for ele in self.elements 

818 if type(ele) in self.whitelist_classes] 

819 

820 def _calc_rated_power(self, name) -> ureg.Quantity: 

821 """ Calculate the rated power adding the rated power of the 

822 whitelist_classes elements. 

823 """ 

824 return sum([ele.rated_power for ele in self.whitelist_elements]) 

825 

826 rated_power = attribute.Attribute( 

827 description="rated power", 

828 unit=ureg.kilowatt, 

829 functions=[_calc_rated_power], 

830 dependant_elements='whitelist_elements' 

831 ) 

832 

833 has_pump = attribute.Attribute( 

834 description="Cycle has a pump system", 

835 functions=[HVACAggregationMixin._calc_has_pump] 

836 ) 

837 

838 def _calc_rated_pump_power(self, name) -> ureg.Quantity: 

839 """ Calculate the rated pump power adding the rated power of the 

840 pump-like elements. 

841 """ 

842 return sum([ele.rated_power for ele in self.pump_elements]) 

843 

844 rated_pump_power = attribute.Attribute( 

845 description="rated pump power", 

846 unit=ureg.kilowatt, 

847 functions=[_calc_rated_pump_power], 

848 dependant_elements='pump_elements' 

849 ) 

850 

851 def _calc_volume_flow(self, name) -> ureg.Quantity: 

852 """ Calculate the volume flow, adding the volume flow of the pump-like 

853 elements. 

854 """ 

855 return sum([ele.rated_volume_flow for ele in self.pump_elements]) 

856 

857 rated_volume_flow = attribute.Attribute( 

858 description="rated volume flow", 

859 unit=ureg.meter ** 3 / ureg.hour, 

860 functions=[_calc_volume_flow], 

861 dependant_elements='pump_elements' 

862 ) 

863 

864 def _calc_flow_temperature(self, name) -> ureg.Quantity: 

865 """ Calculate the flow temperature, using the flow temperature of the 

866 whitelist_classes elements. 

867 """ 

868 return sum(ele.flow_temperature.to_base_units() for ele 

869 in self.whitelist_elements) / len(self.whitelist_elements) 

870 

871 flow_temperature = attribute.Attribute( 

872 description="temperature inlet", 

873 unit=ureg.kelvin, 

874 functions=[_calc_flow_temperature], 

875 dependant_elements='whitelist_elements' 

876 ) 

877 

878 def _calc_return_temperature(self, name) -> ureg.Quantity: 

879 """ Calculate the return temperature, using the return temperature of 

880 the whitelist_classes elements. 

881 """ 

882 return sum(ele.return_temperature.to_base_units() for ele 

883 in self.whitelist_elements) / len(self.whitelist_elements) 

884 

885 return_temperature = attribute.Attribute( 

886 description="temperature outlet", 

887 unit=ureg.kelvin, 

888 functions=[_calc_return_temperature], 

889 dependant_elements='whitelist_elements' 

890 ) 

891 

892 def _calc_dT_water(self, name): 

893 """ Water dt of consumer.""" 

894 return self.flow_temperature - self.return_temperature 

895 

896 dT_water = attribute.Attribute( 

897 description="Nominal temperature difference", 

898 unit=ureg.kelvin, 

899 functions=[_calc_dT_water], 

900 ) 

901 

902 def _calc_body_mass(self, name): 

903 """ Body mass of consumer.""" 

904 return sum(ele.body_mass for ele in self.whitelist_elements) 

905 

906 body_mass = attribute.Attribute( 

907 description="Body mass of Consumer", 

908 functions=[_calc_body_mass], 

909 unit=ureg.kg, 

910 ) 

911 

912 def _calc_heat_capacity(self, name): 

913 """ Heat capacity of consumer.""" 

914 return sum(ele.heat_capacity for ele in 

915 self.whitelist_elements) 

916 

917 heat_capacity = attribute.Attribute( 

918 description="Heat capacity of Consumer", 

919 functions=[_calc_heat_capacity], 

920 unit=ureg.joule / ureg.kelvin, 

921 ) 

922 

923 def _calc_demand_type(self, name): 

924 """ Demand type of consumer.""" 

925 return 1 if self.dT_water > 0 else -1 

926 

927 demand_type = attribute.Attribute( 

928 description="Type of demand if 1 - heating, if -1 - cooling", 

929 functions=[_calc_demand_type], 

930 ) 

931 

932 volume = attribute.Attribute( 

933 description="volume", 

934 unit=ureg.meter ** 3, 

935 ) 

936 

937 def _calc_rated_height(self, name) -> ureg.Quantity: 

938 """ Calculate the rated height power, using the maximal rated height of 

939 the pump-like elements. 

940 """ 

941 return max([ele.rated_height for ele in self.pump_elements]) 

942 

943 rated_height = attribute.Attribute( 

944 description="rated volume flow", 

945 unit=ureg.meter, 

946 functions=[_calc_rated_height], 

947 dependant_elements='pump_elements' 

948 ) 

949 

950 def _calc_description(self, name) -> str: 

951 """ Obtains the aggregation description using the whitelist_classes 

952 elements. 

953 """ 

954 con_types = {} 

955 for ele in self.whitelist_elements: 

956 if type(ele) not in con_types: 

957 con_types[type(ele)] = 0 

958 con_types[type(ele)] += 1 

959 

960 return ', '.join(['{1} x {0}'.format(k.__name__, v) for k, v 

961 in con_types.items()]) 

962 

963 description = attribute.Attribute( 

964 description="String with number of Consumers", 

965 functions=[_calc_description], 

966 dependant_elements='whitelist_elements' 

967 ) 

968 

969 t_control = attribute.Attribute( 

970 description="Bool for temperature control cycle.", 

971 functions=[_calc_TControl] 

972 ) 

973 

974 

975class ConsumerHeatingDistributorModule(HVACAggregationMixin, hvac.HVACProduct): 

976 """ A class that aggregates (several) consumers including the distributor. 

977 

978 Attributes: 

979 multi: A Tuple of attributes to consider in aggregation. 

980 aggregatable_classes: A dict of element classes that can be 

981 aggregated into a ConsumerDistributorModule. 

982 whitelist_classes: A dict of element classes that should be included 

983 when searching for a ConsumerDistributorModule in an HVAC graph. 

984 blacklist_classes: A dict of element classes that should be excluded 

985 when searching for a ConsumerDistributorModule in an HVAC graph. 

986 boarder_classes: Dictionary of classes that are used as boarders. 

987 """ 

988 

989 multi = ( 

990 'medium', 'use_hydraulic_separator', 'hydraulic_separator_volume', 

991 'temperature_inlet', 'temperature_outlet') 

992 # TODO: Abused to not just sum attributes from elements 

993 aggregatable_classes = { 

994 hvac.SpaceHeater, hvac.Pipe, hvac.PipeFitting, hvac.Distributor, 

995 PipeStrand, Consumer, hvac.Junction, hvac.ThreeWayValve } 

996 whitelist_classes = { 

997 hvac.SpaceHeater, UnderfloorHeating, Consumer} 

998 blacklist_classes = {hvac.Chiller, hvac.Boiler, hvac.CoolingTower} 

999 boarder_classes = {hvac.Distributor} 

1000 

1001 def __init__(self, base_graph, match_graph, *args, **kwargs): 

1002 self.undefined_consumer_ports = kwargs.pop( 

1003 'undefined_consumer_ports', None) 

1004 self._consumer_cycles = kwargs.pop('consumer_cycles', None) 

1005 self.consumers = {con for consumer in self._consumer_cycles for con in 

1006 consumer if con.__class__ in self.whitelist_classes} 

1007 self.open_consumer_pairs = self._register_open_consumer_ports() 

1008 super().__init__(base_graph, match_graph, *args, **kwargs) 

1009 # add open consumer ports to found ports by get_ports() 

1010 for con_ports in self.open_consumer_pairs: 

1011 self.ports.append(HVACAggregationPort(con_ports, parent=self)) 

1012 

1013 def _register_open_consumer_ports(self): 

1014 """ This function registers open consumer ports by pairing up loose 

1015 ends at the distributor. If there is an odd number of loose ends, 

1016 it raises a NotImplementedError. 

1017 

1018 Returns: 

1019 list: A list of pairs of open consumer ports. 

1020 

1021 Raises: 

1022 NotImplementedError: If there is an odd number of loose ends at 

1023 the distributor. 

1024 """ 

1025 if (len(self.undefined_consumer_ports) % 2) == 0: 

1026 consumer_ports = self.undefined_consumer_ports 

1027 else: 

1028 raise NotImplementedError( 

1029 "Odd Number of loose ends at the distributor.") 

1030 return consumer_ports 

1031 

1032 @classmethod 

1033 def find_matches(cls, base_graph: HvacGraph 

1034 ) -> Tuple[List[HvacGraph], List[dict]]: 

1035 """ Finds matches of consumer heating distributor modules in the given 

1036 graph. 

1037 

1038 Args: 

1039 base_graph: The graph to be checked for consumer heating distributor 

1040 modules. 

1041 

1042 Returns: 

1043 A tuple containing two lists: 

1044 - matches_graphs: contains the HVAC graphs that hold the 

1045 consumer heating distributor module. 

1046 The second list 

1047 - metas: contains the meta information for each consumer 

1048 heating distributor modules as a dictionary. 

1049 """ 

1050 distributors = {ele for ele in base_graph.elements 

1051 if type(ele) in cls.boarder_classes} 

1052 matches_graphs = [] 

1053 metas = [] 

1054 for distributor in distributors: 

1055 _graph = base_graph.copy() 

1056 _graph.remove_nodes_from(distributor.ports) 

1057 consumer_cycle_elements = [] 

1058 metas.append({'undefined_consumer_ports': [], 

1059 'consumer_cycles': []}) 

1060 cycles = nx.connected_components(_graph) 

1061 for cycle in cycles: 

1062 cycle_graph = base_graph.subgraph(cycle) 

1063 # check for blacklist_classes in cycle, i.e. generators 

1064 generator = {ele for ele in cycle_graph.elements if 

1065 ele.__class__ in cls.blacklist_classes} 

1066 if generator: 

1067 # check for whitelist_classes in cycle that contains a 

1068 # generator 

1069 gen_con = {ele for ele in cycle_graph.elements if 

1070 ele.__class__ in cls.whitelist_classes} 

1071 if gen_con: 

1072 # TODO: separate consumer (maybe recursive function?) 

1073 pass 

1074 else: 

1075 consumer_cycle = {ele for ele in cycle_graph.elements if 

1076 ele.__class__ in cls.whitelist_classes} 

1077 if consumer_cycle: 

1078 consumer_cycle_elements.extend(cycle_graph.elements) 

1079 metas[-1]['consumer_cycles'].append( 

1080 consumer_cycle_elements) 

1081 else: 

1082 # cycle does not hold a consumer might be undefined 

1083 # consumer ports 

1084 metas[-1]['undefined_consumer_ports'].extend( 

1085 [neighbor for cycle_node in list(cycle_graph.nodes) 

1086 for neighbor in base_graph.neighbors(cycle_node) 

1087 if neighbor.parent == distributor]) 

1088 

1089 match_graph = base_graph.subgraph_from_elements( 

1090 consumer_cycle_elements + [distributor]) 

1091 matches_graphs.append(match_graph) 

1092 

1093 return matches_graphs, metas 

1094 

1095 @attribute.multi_calc 

1096 # TODO fix hardcoded values 

1097 def _calc_avg(self): 

1098 result = dict( 

1099 medium=None, 

1100 use_hydraulic_separator=False, 

1101 hydraulic_separator_volume=1, 

1102 ) 

1103 return result 

1104 

1105 medium = attribute.Attribute( 

1106 description="Medium of the DestributerCycle", 

1107 functions=[_calc_avg] 

1108 ) 

1109 

1110 @property 

1111 def whitelist_elements(self) -> list: 

1112 """list of whitelist_classes elements present on the aggregation""" 

1113 return [ele for ele in self.elements if type(ele) in self.whitelist_classes] 

1114 

1115 def _calc_flow_temperature(self, name) -> list: 

1116 """Calculate the flow temperature, using the flow temperature of the 

1117 whitelist_classes elements""" 

1118 return [ele.flow_temperature.to_base_units() for ele 

1119 in self.whitelist_elements] 

1120 

1121 def _calc_has_pump(self, name) -> list[bool]: 

1122 """Returns a list with boolean for every consumer if it has a pump.""" 

1123 return [con.has_pump for con in self.whitelist_elements] 

1124 

1125 flow_temperature = attribute.Attribute( 

1126 description="temperature inlet", 

1127 unit=ureg.kelvin, 

1128 functions=[_calc_flow_temperature], 

1129 dependant_elements='whitelist_elements' 

1130 ) 

1131 

1132 has_pump = attribute.Attribute( 

1133 description="List with bool for every consumer if it has a pump", 

1134 functions=[_calc_has_pump] 

1135 ) 

1136 

1137 def _calc_return_temperature(self, name) -> list: 

1138 """Calculate the return temperature, using the return temperature of the 

1139 whitelist_classes elements""" 

1140 return [ele.return_temperature.to_base_units() for ele 

1141 in self.whitelist_elements] 

1142 

1143 return_temperature = attribute.Attribute( 

1144 description="temperature outlet", 

1145 unit=ureg.kelvin, 

1146 functions=[_calc_return_temperature], 

1147 dependant_elements='whitelist_elements' 

1148 ) 

1149 

1150 def _calc_dT_water(self, name): 

1151 """water dt of consumer""" 

1152 return [ele.dT_water.to_base_units() for ele 

1153 in self.whitelist_elements] 

1154 

1155 dT_water = attribute.Attribute( 

1156 description="Nominal temperature difference", 

1157 unit=ureg.kelvin, 

1158 functions=[_calc_dT_water], 

1159 dependant_elements='whitelist_elements' 

1160 ) 

1161 

1162 def _calc_body_mass(self, name): 

1163 """heat capacity of consumer""" 

1164 return [ele.body_mass for ele in self.whitelist_elements] 

1165 

1166 body_mass = attribute.Attribute( 

1167 description="Body mass of Consumer", 

1168 functions=[_calc_body_mass], 

1169 unit=ureg.kg, 

1170 ) 

1171 

1172 def _calc_heat_capacity(self, name): 

1173 """heat capacity of consumer""" 

1174 return [ele.heat_capacity for ele in self.whitelist_elements] 

1175 

1176 heat_capacity = attribute.Attribute( 

1177 description="Heat capacity of Consumer", 

1178 functions=[_calc_heat_capacity], 

1179 unit=ureg.joule / ureg.kelvin, 

1180 ) 

1181 

1182 def _calc_demand_type(self, name): 

1183 """demand type of consumer""" 

1184 return [ele.demand_type for ele in self.whitelist_elements] 

1185 

1186 demand_type = attribute.Attribute( 

1187 description="Type of demand if 1 - heating, if -1 - cooling", 

1188 functions=[_calc_demand_type], 

1189 dependant_elements='whitelist_elements' 

1190 ) 

1191 

1192 def calc_mass_flow(self, name): 

1193 """Returns the mass flow, in the form of a list with the mass flow of 

1194 the whitelist_classes-like elements""" 

1195 return [ele.rated_mass_flow for ele in self.whitelist_elements] 

1196 

1197 rated_mass_flow = attribute.Attribute( 

1198 description="rated mass flow", 

1199 functions=[calc_mass_flow], 

1200 unit=ureg.kg / ureg.s, 

1201 dependant_elements='whitelist_elements' 

1202 ) 

1203 

1204 use_hydraulic_separator = attribute.Attribute( 

1205 description="boolean if there is a hdydraulic seperator", 

1206 functions=[_calc_avg] 

1207 ) 

1208 

1209 hydraulic_separator_volume = attribute.Attribute( 

1210 description="Volume of the hdydraulic seperator", 

1211 functions=[_calc_avg], 

1212 unit=ureg.m ** 3, 

1213 ) 

1214 

1215 def _calc_rated_power(self, name): 

1216 """Returns the rated power, as a list of the rated power of the 

1217 whitelist_elements elements""" 

1218 return [ele.rated_power for ele in self.whitelist_elements] 

1219 

1220 rated_power = attribute.Attribute( 

1221 description="Rated heating power of all consumers", 

1222 unit=ureg.kilowatt, 

1223 functions=[_calc_rated_power], 

1224 dependant_elements='whitelist_elements' 

1225 ) 

1226 

1227 def _calc_TControl(self, name) -> list[bool]: 

1228 return [con.t_control for con in self.whitelist_elements] 

1229 

1230 t_control = attribute.Attribute( 

1231 description="List with bool for every consumer if it has a feedback " 

1232 "cycle for temperature control.", 

1233 functions=[_calc_TControl] 

1234 ) 

1235 

1236 def _calc_temperature_array(self): 

1237 return [self.flow_temperature, self.return_temperature] 

1238 

1239 temperature_array = attribute.Attribute( 

1240 description="Array of flow and return temperature", 

1241 functions=[_calc_temperature_array] 

1242 ) 

1243 

1244 

1245class GeneratorOneFluid(HVACAggregationMixin, hvac.HVACProduct): 

1246 """ Aggregates generator modules with only one fluid cycle (CHPs, Boilers, 

1247 ...) 

1248 

1249 Not for Chillers or Heat-pumps! 

1250 """ 

1251 aggregatable_classes = { 

1252 hvac.Pump, PipeStrand, hvac.Pipe, hvac.PipeFitting, hvac.Distributor, 

1253 hvac.Boiler, ParallelPump, hvac.Valve, hvac.Storage, 

1254 hvac.ThreeWayValve, hvac.Junction, ConsumerHeatingDistributorModule, 

1255 Consumer} 

1256 whitelist_classes = {hvac.Boiler, hvac.CHP} 

1257 boarder_classes = {hvac.Distributor, ConsumerHeatingDistributorModule} 

1258 multi = ('rated_power', 'has_bypass', 'rated_height', 'volume', 

1259 'rated_volume_flow', 'rated_pump_power', 'has_pump') 

1260 

1261 def __init__(self, base_graph, match_graph, *args, **kwargs): 

1262 self.non_relevant = kwargs.pop('non_relevant', set()) 

1263 self.has_parallel = kwargs.pop('has_parallel', False) 

1264 self.bypass_elements = kwargs.pop('bypass_elements', set()) 

1265 self.has_bypass = True if self.bypass_elements else False 

1266 super().__init__(base_graph, match_graph, *args, **kwargs) 

1267 

1268 @classmethod 

1269 def find_matches(cls, base_graph: HvacGraph 

1270 ) -> Tuple[List[HvacGraph], List[dict]]: 

1271 """ Finds matches of generators with one fluid. 

1272 

1273 Non-relevant elements like bypasses are added to metas 

1274 information to delete later. 

1275 

1276 Args: 

1277 base_graph: HVAC graph that should be checked for one fluid 

1278 generators 

1279 

1280 Returns: 

1281 A tuple containing two lists: 

1282 - matches_graphs: List of HVAC graphs that hold a generator 

1283 cycle including the distributor. 

1284 - metas: List of dict with meta information for each generator 

1285 as a dictionary. In this case it holds non_relevant 

1286 nodes, which have to be deleted later but are not 

1287 contained in the match_graph .Because we are currently not 

1288 able to distinguish to which graph these non_relevant 

1289 nodes belong, we just output the complete list of 

1290 non-relevant nodes for every element_graph. 

1291 """ 

1292 element_graph = base_graph.element_graph 

1293 inerts = cls.aggregatable_classes - cls.whitelist_classes 

1294 _graph = HvacGraph.remove_not_wanted_nodes( 

1295 element_graph, cls.whitelist_classes, inerts) 

1296 dict_all_cycles_wanted = HvacGraph.get_all_cycles_with_wanted( 

1297 _graph, cls.whitelist_classes) 

1298 list_all_cycles_wanted = [*dict_all_cycles_wanted.values()] 

1299 

1300 # create flat lists to subtract for non-relevant 

1301 generator_flat = set() 

1302 wanted_flat = set() 

1303 

1304 # check for generation cycles 

1305 generator_cycles = [] 

1306 for cycles_list in list_all_cycles_wanted: 

1307 generator_cycle = list( 

1308 nx.subgraph(_graph, cycle) for cycle in cycles_list 

1309 if any(type(node) == block for block in 

1310 cls.boarder_classes for node in cycle)) 

1311 if generator_cycle: 

1312 generator_cycles.extend(generator_cycle) 

1313 generator_flat.update(generator_cycle[0].nodes) 

1314 wanted_flat.update( 

1315 [item for sublist in cycles_list for item in sublist]) 

1316 

1317 cleaned_generator_cycles = [] 

1318 metas = [] 

1319 if generator_flat: 

1320 non_relevant = wanted_flat - generator_flat 

1321 

1322 # remove overlapping elements in GeneratorCycles 

1323 for gen_cycle in generator_cycles: 

1324 pseudo_lst = gen_cycle.copy() 

1325 for gen_cycle_two in generator_cycles: 

1326 if gen_cycle == gen_cycle_two: 

1327 continue 

1328 pseudo_lst.remove_nodes_from(gen_cycle_two) 

1329 cleaned_generator_cycles.append(pseudo_lst) 

1330 _graph = base_graph.copy() 

1331 

1332 # match_graph bypass elements from non relevant elements 

1333 for i in range(len(cleaned_generator_cycles)): 

1334 metas.append(dict()) 

1335 metas[i]['bypass_elements'] = [] 

1336 for cycle in list_all_cycles_wanted[i]: 

1337 if len(cycle - cleaned_generator_cycles[i].nodes 

1338 - non_relevant) > 0: 

1339 continue 

1340 bypass_elements = cycle - cleaned_generator_cycles[i].nodes 

1341 cleaned_generator_cycles[i].add_nodes_from(bypass_elements) 

1342 non_relevant.difference_update(bypass_elements) 

1343 metas[i]['bypass_elements'].append(bypass_elements) 

1344 

1345 if len(metas) > 0: 

1346 metas[0]['non_relevant'] = non_relevant 

1347 

1348 matches_graphs = [] 

1349 for cycle in cleaned_generator_cycles: 

1350 match_graph = base_graph.subgraph_from_elements(list(cycle.nodes)) 

1351 match_graph = HvacGraph.remove_classes_from( 

1352 match_graph, cls.boarder_classes) 

1353 matches_graphs.append(match_graph) 

1354 return matches_graphs, metas 

1355 

1356 @attribute.multi_calc 

1357 def _calc_avg(self): 

1358 """ Calculates the parameters of all the below listed elements.""" 

1359 avg_diameter_strand = 0 

1360 total_length = 0 

1361 diameter_times_length = 0 

1362 

1363 for element in self.not_whitelist_elements: 

1364 if hasattr(element, "diameter") and hasattr(element, "length"): 

1365 length = element.length 

1366 diameter = element.diameter 

1367 if not (length and diameter): 

1368 logger.info("Ignored '%s' in aggregation", element) 

1369 continue 

1370 

1371 diameter_times_length += diameter * length 

1372 total_length += length 

1373 

1374 else: 

1375 logger.info("Ignored '%s' in aggregation", element) 

1376 

1377 if total_length != 0: 

1378 avg_diameter_strand = diameter_times_length / total_length 

1379 

1380 result = dict( 

1381 length=total_length, 

1382 diameter_strand=avg_diameter_strand 

1383 ) 

1384 return result 

1385 

1386 @attribute.multi_calc 

1387 def _calc_has_bypass(self): 

1388 decision = BoolDecision( 

1389 "Does the generator %s has a bypass?" % self.name, 

1390 global_key=self.guid + '.bypass', 

1391 allow_save=True, 

1392 allow_load=True, 

1393 related=[element.guid for element in self.elements], ) 

1394 has_bypass = decision.decide() 

1395 return dict(has_bypass=has_bypass) 

1396 

1397 @classmethod 

1398 def find_bypasses(cls, graph): 

1399 """ Finds bypasses based on the graphical network. 

1400 

1401 Currently not used, might be removed in the future. 

1402 """ 

1403 # todo remove if discussed 

1404 wanted = set(cls.whitelist_classes) 

1405 boarders = set(cls.boarder_classes) 

1406 inerts = set(cls.aggregatable_elements) - wanted 

1407 bypass_nodes = HvacGraph.detect_bypasses_to_wanted( 

1408 graph, wanted, inerts, boarders) 

1409 return bypass_nodes 

1410 

1411 def _calc_has_bypass_decision(self): 

1412 """ Checks if bypass exists based on decision. Currently not used as 

1413 only possible with workaround. See todo documentation""" 

1414 # todo remove if discussed, see #184 

1415 # todo more elegant way? Problem is that cant yield from attributes 

1416 # or cached propertys @earnsdev. Maybe using 

1417 # get_pending_attribute_decisions() in combination with request? 

1418 decisions = DecisionBunch() 

1419 cur_decision = BoolDecision( 

1420 "Does the generator %s has a bypass?" % self.guid, 

1421 key=self, 

1422 global_key=self.guid + '.bypass', 

1423 related=[element.guid for element in self.elements], ) 

1424 decisions.append(cur_decision) 

1425 yield decisions 

1426 answers = decisions.to_answer_dict() 

1427 has_bypass = list(answers.values())[0] 

1428 self.has_bypass = has_bypass 

1429 return has_bypass 

1430 

1431 @property 

1432 def whitelist_elements(self) -> list: 

1433 """ List of whitelist_classes elements present on the aggregation""" 

1434 return [ele for ele in self.elements if type(ele) 

1435 in self.whitelist_classes] 

1436 

1437 @property 

1438 def not_whitelist_elements(self) -> list: 

1439 """ List of not-whitelist_classes elements present on the aggregation""" 

1440 return [ele for ele in self.elements if type(ele) not 

1441 in self.whitelist_classes] 

1442 

1443 def _calc_rated_power(self, name) -> ureg.Quantity: 

1444 """ Calculate the rated power adding the rated power of the 

1445 whitelist_classes elements.""" 

1446 return sum([ele.rated_power for ele in self.whitelist_elements]) 

1447 

1448 rated_power = attribute.Attribute( 

1449 unit=ureg.kilowatt, 

1450 description="rated power", 

1451 functions=[_calc_rated_power], 

1452 dependant_elements='whitelist_elements' 

1453 ) 

1454 

1455 def _calc_min_power(self, name): 

1456 """ Calculates the min power, adding the min power of the 

1457 whitelist_elements.""" 

1458 return sum([ele.min_power for ele in self.whitelist_elements]) 

1459 

1460 min_power = attribute.Attribute( 

1461 unit=ureg.kilowatt, 

1462 description="min power", 

1463 functions=[_calc_min_power], 

1464 dependant_elements='whitelist_elements' 

1465 ) 

1466 

1467 def _calc_min_PLR(self, name): 

1468 """ Calculates the min PLR, using the min power and rated power.""" 

1469 return self.min_power / self.rated_power 

1470 

1471 min_PLR = attribute.Attribute( 

1472 description="Minimum part load ratio", 

1473 unit=ureg.dimensionless, 

1474 functions=[_calc_min_PLR], 

1475 ) 

1476 

1477 def _calc_flow_temperature(self, name) -> ureg.Quantity: 

1478 """ Calculate the flow temperature, using the flow temperature of the 

1479 whitelist_classes elements.""" 

1480 return sum(ele.flow_temperature.to_base_units() for ele 

1481 in self.whitelist_elements) / len(self.whitelist_elements) 

1482 

1483 flow_temperature = attribute.Attribute( 

1484 description="Nominal inlet temperature", 

1485 unit=ureg.kelvin, 

1486 functions=[_calc_flow_temperature], 

1487 dependant_elements='whitelist_elements' 

1488 ) 

1489 

1490 def _calc_return_temperature(self, name) -> ureg.Quantity: 

1491 """ Calculate the return temperature, using the return temperature of 

1492 the whitelist_classes elements.""" 

1493 return sum(ele.return_temperature.to_base_units() for ele 

1494 in self.whitelist_elements) / len(self.whitelist_elements) 

1495 

1496 return_temperature = attribute.Attribute( 

1497 description="Nominal outlet temperature", 

1498 unit=ureg.kelvin, 

1499 functions=[_calc_return_temperature], 

1500 dependant_elements='whitelist_elements' 

1501 ) 

1502 

1503 def _calc_dT_water(self, name): 

1504 """ Rated power of boiler.""" 

1505 return abs(self.return_temperature - self.flow_temperature) 

1506 

1507 dT_water = attribute.Attribute( 

1508 description="Nominal temperature difference", 

1509 unit=ureg.kelvin, 

1510 functions=[_calc_dT_water], 

1511 ) 

1512 

1513 def _calc_diameter(self, name) -> ureg.Quantity: 

1514 """ Calculate the diameter, using the whitelist_classes elements 

1515 diameter.""" 

1516 return sum( 

1517 item.diameter ** 2 for item in self.whitelist_elements) ** 0.5 

1518 

1519 diameter = attribute.Attribute( 

1520 description='diameter', 

1521 unit=ureg.millimeter, 

1522 functions=[_calc_diameter], 

1523 dependant_elements='whitelist_elements' 

1524 ) 

1525 

1526 length = attribute.Attribute( 

1527 description='length of aggregated pipe elements', 

1528 functions=[_calc_avg], 

1529 unit=ureg.meter, 

1530 ) 

1531 

1532 diameter_strand = attribute.Attribute( 

1533 description='average diameter of aggregated pipe elements', 

1534 functions=[_calc_avg], 

1535 unit=ureg.millimeter, 

1536 ) 

1537 

1538 has_pump = attribute.Attribute( 

1539 description="Cycle has a pumpsystem", 

1540 functions=[HVACAggregationMixin._calc_has_pump] 

1541 ) 

1542 

1543 @property 

1544 def pump_elements(self) -> list: 

1545 """ List of pump-like elements present on the aggregation""" 

1546 return [ele for ele in self.elements if isinstance(ele, hvac.Pump)] 

1547 

1548 def _calc_rated_pump_power(self, name) -> ureg.Quantity: 

1549 """ Calculate the rated pump power adding the rated power of the 

1550 pump-like elements.""" 

1551 if all(ele.rated_power for ele in self.pump_elements): 

1552 return sum([ele.rated_power for ele in self.pump_elements]) 

1553 else: 

1554 return None 

1555 

1556 rated_pump_power = attribute.Attribute( 

1557 description="rated pump power", 

1558 unit=ureg.kilowatt, 

1559 functions=[_calc_rated_pump_power], 

1560 dependant_elements='pump_elements' 

1561 ) 

1562 

1563 def _calc_volume_flow(self, name) -> ureg.Quantity: 

1564 """ Calculate the volume flow, adding the volume flow of the pump-like 

1565 elements.""" 

1566 return sum([ele.rated_volume_flow for ele in self.pump_elements]) 

1567 

1568 rated_volume_flow = attribute.Attribute( 

1569 description="rated volume flow", 

1570 unit=ureg.m ** 3 / ureg.s, 

1571 functions=[_calc_volume_flow], 

1572 dependant_elements='pump_elements' 

1573 ) 

1574 

1575 def _calc_volume(self, name): 

1576 """ Calculates volume of GeneratorOneFluid.""" 

1577 return NotImplementedError 

1578 

1579 volume = attribute.Attribute( 

1580 description="Volume of Boiler", 

1581 unit=ureg.m ** 3, 

1582 functions=[_calc_volume] 

1583 ) 

1584 

1585 def _calc_rated_height(self, name) -> ureg.Quantity: 

1586 """ Calculate the rated height power, using the maximal rated height of 

1587 the pump-like elements.""" 

1588 return max([ele.rated_height for ele in self.pump_elements]) 

1589 

1590 rated_height = attribute.Attribute( 

1591 description="rated volume flow", 

1592 unit=ureg.m, 

1593 functions=[_calc_rated_height], 

1594 dependant_elements='pump_elements' 

1595 )