Repository: oemof/tespy

Issue:
Improve the subsystem API
I received a suggestion how the subsystem API could be improved, which looks as follows:

- A `Subsystem` should be accessible from the network by its label through a respective interface (e.g. `get_subsystem`)
- A `Connection` should be able to connect to a `Subsystem` by mapping a standard inlet and outlet component. There will be an `inlet` and an `outlet` attribute of the `Subsystem`, which will map to a `SubsystemInterface`. Specifying `Connection(source, "out1", subsystem, "in1")` will in that case be rerouted to the `SubsystemInterface` component inside the subsystem.
- The same access methods as are available in the `Network` class for connections and components should be added to the `Subsystem` class to get instances by their label.
- The addition of connections and components to the `Network` from a given `Subsystem` should automatically prefix the `Subsystem.label` to these on the `Network` level. This is to avoid having to use the prefixed version of the label to get these objects from inside the `Subsystem`.

More thoughts:

- There could be a result reporting exclusive to a selected subsystem, e.g. `subsystem.print_results()`
- The same could be applied for exporting results (i.e. export the results of a specific subsystem only)

Discussion:

Solution patch:
diff --git a/docs/modules/components.rst b/docs/modules/components.rst
index 9d58483c..ee11e2a3 100644
--- a/docs/modules/components.rst
+++ b/docs/modules/components.rst
@@ -777,12 +777,14 @@ Custom subsystems
 -----------------
 
 Create a :code:`.py` file in your working-directory. This file contains the
-class definition of your subsystem and at minimum two methods:
+class definition of your subsystem and at minimum one method:
 
-- :code:`create_comps`: Method to create the components of your subsystem and
-  save them in the :code:`Subsystem.comps` attribute (dictionary).
-- :code:`create_conns`: Method to create the connections of your subsystem and
-  save them in the :code:`Subsystem.conns` attribute (dictionary).
+- :code:`create_network`: Method to create the network of your subsystem.
+
+On top of that you need to add methods to define the available interfaces of
+your subsystem to the remaining network through specifying the number of inlets
+and outlets in the :code:`__init__` method of your class as seen in the code
+example below.
 
 All other functionalities are inherited by the parent class of the
 :py:class:`subsystem <tespy.components.subsystem.Subsystem>` object.
@@ -826,28 +828,33 @@ Create a file, e.g. :code:`mysubsystems.py` and add the following lines:
 
     >>> class WasteHeatSteamGenerator(Subsystem):
     ...     """Class documentation"""
+    ...     def __init__(self, label):
+    ...         self.num_in = 2
+    ...         self.num_out = 2
+    ...         super().__init__(label)
     ...
-    ...     def create_comps(self):
-    ...         """Create the subsystem's components."""
-    ...         self.comps['eco'] = HeatExchanger(f'{self.label}_economizer')
-    ...         self.comps['eva'] = HeatExchanger(f'{self.label}_evaporator')
-    ...         self.comps['sup'] = HeatExchanger(f'{self.label}_superheater')
-    ...         self.comps['drum'] = Drum(f'{self.label}_drum')
-    ...
-    ...     def create_conns(self):
+    ...     def create_network(self):
     ...         """Define the subsystem's connections."""
-    ...         self.conns['eco_dr'] = Connection(
-    ...             self.comps['eco'], 'out2', self.comps['drum'], 'in1')
-    ...         self.conns['dr_eva'] = Connection(
-    ...             self.comps['drum'], 'out1', self.comps['eva'], 'in2')
-    ...         self.conns['eva_dr'] = Connection(
-    ...             self.comps['eva'], 'out2', self.comps['drum'], 'in2')
-    ...         self.conns['dr_sup'] = Connection(
-    ...             self.comps['drum'], 'out2', self.comps['sup'], 'in2')
-    ...         self.conns['sup_eva'] = Connection(
-    ...             self.comps['sup'], 'out1', self.comps['eva'], 'in1')
-    ...         self.conns['eva_eco'] = Connection(
-    ...             self.comps['eva'], 'out1', self.comps['eco'], 'in1')
+    ...         eco = HeatExchanger('economizer')
+    ...         eva = HeatExchanger('evaporator')
+    ...         sup = HeatExchanger('superheater')
+    ...         drum = Drum('drum')
+    ...
+    ...         inlet_eco = Connection(self.inlet, 'out2', eco, 'in2', label='1')
+    ...         eco_dr = Connection(eco, 'out2', drum, 'in1', label='2')
+    ...         dr_eva = Connection(drum, 'out1', eva, 'in2', label='3')
+    ...         eva_dr = Connection(eva, 'out2', drum, 'in2', label='4')
+    ...         dr_sup = Connection(drum, 'out2', sup, 'in2', label='5')
+    ...         sup_outlet = Connection(sup, 'out2', self.outlet, 'in2', label='6')
+    ...
+    ...         self.add_conns(inlet_eco, eco_dr, dr_eva, eva_dr, dr_sup, sup_outlet)
+    ...
+    ...         inlet_sup = Connection(self.inlet, 'out1', sup, 'in1', label='11')
+    ...         sup_eva = Connection(sup, 'out1', eva, 'in1', label='12')
+    ...         eva_eco = Connection(eva, 'out1', eco, 'in1', label='13')
+    ...         eco_outlet = Connection(eco, 'out1', self.outlet, 'in1', label='14')
+    ...
+    ...         self.add_conns(inlet_sup, sup_eva, eva_eco, eco_outlet)
 
 .. note::
 
@@ -881,13 +888,13 @@ different tespy classes required.
     >>> sg = WasteHeatSteamGenerator('waste heat steam generator')
 
     >>> # %% connection definition
-    >>> fw_sg = Connection(feed_water, 'out1', sg.comps['eco'], 'in2')
-    >>> sg_ls = Connection(sg.comps['sup'], 'out2', steam, 'in1')
-    >>> fg_sg = Connection(waste_heat, 'out1', sg.comps['sup'], 'in1')
-    >>> sg_ch = Connection(sg.comps['eco'], 'out1', chimney, 'in1')
+    >>> fw_sg = Connection(feed_water, 'out1', sg, 'in2')
+    >>> sg_ls = Connection(sg, 'out2', steam, 'in1')
+    >>> fg_sg = Connection(waste_heat, 'out1', sg, 'in1')
+    >>> sg_ch = Connection(sg, 'out1', chimney, 'in1')
 
     >>> nw.add_conns(fw_sg, sg_ls, fg_sg, sg_ch)
-    >>> nw.add_subsys(sg)
+    >>> nw.add_subsystems(sg)
 
     >>> # %% connection parameters
     >>> fw_sg.set_attr(fluid={'water': 1}, T=25)
@@ -895,29 +902,30 @@ different tespy classes required.
     >>> sg_ls.set_attr(p=130)
     >>> sg_ch.set_attr(p=1)
 
-    >>> sg.conns['eva_dr'].set_attr(x=0.6)
+    >>> sg.get_conn('4').set_attr(x=0.6)
 
     >>> # %% component parameters
-    >>> sg.comps['eco'].set_attr(
+    >>> sg.get_comp('economizer').set_attr(
     ...     pr1=0.999,  pr2=0.97, design=['pr1', 'pr2', 'ttd_u'],
     ...     offdesign=['zeta1', 'zeta2', 'kA_char']
     ... )
 
-    >>> sg.comps['eva'].set_attr(
+    >>> sg.get_comp('evaporator').set_attr(
     ...     pr1=0.999, ttd_l=20, design=['pr1', 'ttd_l'],
     ...     offdesign=['zeta1', 'kA_char']
     ... )
 
-    >>> sg.comps['sup'].set_attr(
+    >>> sg.get_comp('superheater').set_attr(
     ...     pr1=0.999,  pr2=0.99, ttd_u=50, design=['pr1', 'pr2', 'ttd_u'],
     ...     offdesign=['zeta1', 'zeta2', 'kA_char']
     ... )
 
-    >>> sg.conns['eco_dr'].set_attr(Td_bp=-5, design=['Td_bp'])
+    >>> sg.get_conn('2').set_attr(Td_bp=-5, design=['Td_bp'])
 
     >>> # %% solve
     >>> # solve design case
     >>> nw.solve('design')
+    >>> nw._convergence_check()
     >>> nw.save('tmp.json')
 
     >>> # offdesign test
diff --git a/docs/modules/networks.rst b/docs/modules/networks.rst
index 1ca1290c..a0374224 100644
--- a/docs/modules/networks.rst
+++ b/docs/modules/networks.rst
@@ -95,7 +95,7 @@ or via subsystems using the corresponding methods:
 .. code-block:: python
 
     >>> my_plant.add_conns()
-    >>> my_plant.add_subsys()
+    >>> my_plant.add_subsystems()
 
 .. note::
 
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
index 0f0d6b47..30d10ffc 100644
--- a/docs/whats_new.rst
+++ b/docs/whats_new.rst
@@ -3,6 +3,7 @@ What's New
 
 Discover notable new features and improvements in each release
 
+.. include::  whats_new/v0-8-2.rst
 .. include::  whats_new/v0-8-1.rst
 .. include::  whats_new/v0-8-0-001.rst
 .. include::  whats_new/v0-8-0.rst
diff --git a/docs/whats_new/v0-8-2.rst b/docs/whats_new/v0-8-2.rst
new file mode 100644
index 00000000..85e2be63
--- /dev/null
+++ b/docs/whats_new/v0-8-2.rst
@@ -0,0 +1,17 @@
+v0.8.2 - Under development
+++++++++++++++++++++++++++
+
+API changes
+###########
+- The API of the :code:`Subsystem` class has been revised to make it act more
+  like a :code:`Network`. You can get components and connections from the
+  subsystem with respective methods, and the :code:`SubsystemInterface` class
+  is utilized to target the :code:`subsystem.inlet` and
+  :code:`subsystem.outlet` in a :code:`Connection` object. For the required
+  changes please check the respective section
+  :ref:`in the docs <tespy_subsystems_label>`
+  (`PR #652 <https://github.com/oemof/tespy/pull/652>`__).
+
+Contributors
+############
+- Francesco Witte (`@fwitte <https://github.com/fwitte>`__)
diff --git a/pyproject.toml b/pyproject.toml
index fa631130..c019c945 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,7 @@ exclude = ["docs/_build"]
 
 [project]
 name = "tespy"
-version = "0.8.1"
+version = "0.8.2.dev0"
 description = "Thermal Engineering Systems in Python (TESPy)"
 readme = "README.rst"
 authors = [
diff --git a/src/tespy/__init__.py b/src/tespy/__init__.py
index 6d5c1ad1..44b9ef15 100644
--- a/src/tespy/__init__.py
+++ b/src/tespy/__init__.py
@@ -12,7 +12,7 @@ if sys.version_info[1] < 10:
     warnings.warn(FutureWarning(msg))
 
 __datapath__ = os.path.join(importlib.resources.files("tespy"), "data")
-__version__ = '0.8.1 - Newton\'s Nature'
+__version__ = '0.8.2.dev0'
 
 # tespy data and connections import
 from . import connections  # noqa: F401
diff --git a/src/tespy/components/subsystem.py b/src/tespy/components/subsystem.py
index f17b00cb..be20a58c 100644
--- a/src/tespy/components/subsystem.py
+++ b/src/tespy/components/subsystem.py
@@ -13,6 +13,7 @@ available from its original location tespy/components/subsystems.py
 SPDX-License-Identifier: MIT
 """
 
+from tespy.components import SubsystemInterface
 from tespy.tools import logger
 
 
@@ -31,22 +32,88 @@ class Subsystem:
     run a TESPy calculation!
 
     >>> from tespy.components import Subsystem
-    >>> mysub = Subsystem('mySubsystem')
+    >>> class MySubsystem(Subsystem):
+    ...     def create_network(self):
+    ...         pass
+    >>> mysub = MySubsystem('mySubsystem')
     >>> type(mysub)
-    <class 'tespy.components.subsystem.Subsystem'>
-    >>> mysub.get_attr('label')
+    <class 'tespy.components.subsystem.MySubsystem'>
+    >>> mysub.label
     'mySubsystem'
+    >>> type(mysub.inlet)
+    <class 'tespy.components.basics.subsystem_interface.SubsystemInterface'>
+    >>> type(mysub.outlet)
+    <class 'tespy.components.basics.subsystem_interface.SubsystemInterface'>
+
+    If you want to connect to the subsystem from outside of it in a Network,
+    then you have to pass the respective number of inlet and outlet connections.
+    The number is to your choice, but  for the `Subsystem` to be functional,
+    all of the available interfaces must be wired properly internally in the
+    :code:`create_network` method. For example, consider a subsystem which is
+    just passing its inlet to the outlet:
+
+    >>> from tespy.components import Source, Sink
+    >>> from tespy.connections import Connection
+    >>> from tespy.networks import Network
+    >>> class MySubsystem(Subsystem):
+    ...     def __init__(self, label):
+    ...         self.num_in = 1
+    ...         self.num_out = 1
+    ...         super().__init__(label)
+    ...
+    ...     def create_network(self):
+    ...         c1 = Connection(self.inlet, "out1", self.outlet, "in1", label="1")
+    ...         self.add_conns(c1)
+    >>> mysub = MySubsystem('mySubsystem')
+    >>> nw = Network()
+    >>> so = Source("source")
+    >>> si = Sink("sink")
+    >>> c1 = Connection(so, "out1", mysub, "in1", label="1")
+    >>> c2 = Connection(mysub, "out1", si, "in1", label="2")
+    >>> nw.add_conns(c1, c2)
+    >>> nw.add_subsystems(mysub)
+
+    We can run the :code:`check_network` method to check if everything is
+    properly connected and a valid topology was created, without needing to
+    parametrize the system (for the sake of simplicity in this example).
+
+    >>> nw.check_network()
+
+    You can retrieve components and connections from inside the subsystem with
+    their label, which is used inside the :code:`create_network` method of the
+    subsystem.
+
+    >>> type(mysub.get_conn("1"))
+    <class 'tespy.connections.connection.Connection'>
+    >>> type(mysub.get_comp("inlet"))
+    <class 'tespy.components.basics.subsystem_interface.SubsystemInterface'>
+
+    Their actual label is prefixed with the subsystem's label, and therefore to
+    get it from the network level, you must use that label:
+
+    >>> mysub.get_conn("1").label
+    'mySubsystem_1'
+    >>> type(nw.get_conn('mySubsystem_1'))
+    <class 'tespy.connections.connection.Connection'>
+
+    The same is true for components:
+
+    >>> mysub.get_comp("inlet").label
+    'mySubsystem_inlet'
+    >>> type(nw.get_comp("mySubsystem_inlet"))
+    <class 'tespy.components.basics.subsystem_interface.SubsystemInterface'>
     """
 
     def __init__(self, label):
 
+        forbidden = [';', ', ', '.']
         if not isinstance(label, str):
             msg = 'Subsystem label must be of type str!'
             logger.error(msg)
             raise ValueError(msg)
 
-        elif len([x for x in [';', ', ', '.'] if x in label]) > 0:
-            msg = 'Can\'t use ' + str([';', ', ', '.']) + ' in label.'
+        elif len([x for x in forbidden if x in label]) > 0:
+            msg = f'Can\'t use {", ".join(forbidden)} in label.'
             logger.error(msg)
             raise ValueError(msg)
         else:
@@ -54,34 +121,93 @@ class Subsystem:
 
         self.comps = {}
         self.conns = {}
-        self.create_comps()
-        self.create_conns()
-
-    def get_attr(self, key):
-        r"""
-        Get the value of a subsystem's attribute.
-
-        Parameters
-        ----------
-        key : str
-            The attribute you want to retrieve.
-
-        Returns
-        -------
-        out :
-            Value of specified attribute.
-        """
-        if key in self.__dict__:
-            return self.__dict__[key]
-        else:
-            msg = 'Subsystem ' + self.label + ' has no attribute ' + key + '.'
-            logger.error(msg)
-            raise KeyError(msg)
-
-    def create_comps(self):
-        """Create the subsystem's components."""
-        return
 
-    def create_conns(self):
-        """Create the subsystem's connections."""
-        return
+        if not hasattr(self, "num_in"):
+            msg = (
+                "When creating your own Subsystem class you need to define "
+                "the number of inlets (interfaces to external parts) before "
+                "calling super.__init__() in the 'self.num_in' attribute."
+            )
+            logger.warning(msg)
+            self.num_in = 0
+
+        if not hasattr(self, "num_out"):
+            msg = (
+                "When creating your own Subsystem class you need to define "
+                "the number of outlets (interfaces to external parts) before "
+                "calling super.__init__() in the 'self.num_out' attribute."
+            )
+            logger.warning(msg)
+            self.num_out = 0
+
+        if self.num_in == 0 and self.num_out == 0:
+            msg = (
+                "Your subsystem has no interfaces at all. To make interfaces "
+                "available to connect to outside of the subsystem components "
+                "you have to provide a number of inlets and a number of "
+                "outlets in the same style as they are provided for "
+                "component classes, i.e. by defining the 'inlets' and "
+                "'outlets' methods."
+            )
+            logger.warning(msg)
+
+        self.inlet = SubsystemInterface("inlet", num_inter=self.num_in)
+        self.outlet = SubsystemInterface("outlet", num_inter=self.num_out)
+
+        self.create_network()
+
+    def add_conns(self, *args):
+
+        for conn in args:
+            self.conns[conn.label] = conn
+            self.conns[conn.label].label = f"{self.label}_{conn.label}"
+
+        self._add_comps(*args)
+
+    def _add_comps(self, *args):
+        # get unique components in new connections
+        comps = list({cp for c in args for cp in [c.source, c.target]})
+        # add to the dict of components
+
+        for comp in comps:
+            if comp.label in self.comps.keys():
+                msg = "Component name in subsystem is not unique"
+                raise ValueError(msg)
+
+            self.comps[comp.label] = comp
+            self.comps[comp.label].label = f"{self.label}_{comp.label}"
+
+    def get_conn(self, label):
+
+        try:
+            return self.conns[label]
+        except KeyError:
+            msg = (
+                f"Connection with label {label} not found. Note: The label "
+                "should not include the Subsystem label when retrieving the "
+                "connection from the subsystem."
+            )
+            logger.warning(msg)
+            return None
+
+    def get_comp(self, label):
+
+        try:
+            return self.comps[label]
+        except KeyError:
+            msg = (
+                f"Component with label {label} not found. Note: The label "
+                "should not include the Subsystem label when retrieving the "
+                "component from the subsystem."
+            )
+            logger.warning(msg)
+            return None
+
+    def create_network(self):
+        """Create the subsystem's network."""
+        msg = (
+            "Your subsystem's network has to be set up through the "
+            "'create_network' method. To do this, inherit from the "
+            "Subsystem class and overwrite this method."
+        )
+        raise NotImplementedError(msg)
diff --git a/src/tespy/connections/connection.py b/src/tespy/connections/connection.py
index 9e413752..a31efb68 100644
--- a/src/tespy/connections/connection.py
+++ b/src/tespy/connections/connection.py
@@ -12,6 +12,7 @@ SPDX-License-Identifier: MIT
 import numpy as np
 
 from tespy.components.component import Component
+from tespy.components import Subsystem
 from tespy.tools import fluid_properties as fp
 from tespy.tools import logger
 from tespy.tools.data_containers import DataContainer as dc
@@ -232,6 +233,7 @@ class Connection:
     def __init__(self, source, outlet_id, target, inlet_id,
                  label=None, **kwargs):
 
+        source, target = self._remap_if_subsystem(source, target)
         self._check_types(source, target)
         self._check_self_connect(source, target)
         self._check_connector_id(source, outlet_id, source.outlets())
@@ -279,6 +281,17 @@ class Connection:
 
         self.set_attr(**kwargs)
 
+    def _remap_if_subsystem(self, source, target):
+        # If the connected source or target is a subsystem we must
+        # remap the source and target to its outlet/inlet
+        if isinstance(source, Subsystem):
+            source = source.outlet
+
+        if isinstance(target, Subsystem):
+            target = target.inlet
+
+        return source, target
+
     def _check_types(self, source, target):
         # check input parameters
         if not (isinstance(source, Component) and
diff --git a/src/tespy/networks/network.py b/src/tespy/networks/network.py
index 0ff2328f..62b0c544 100644
--- a/src/tespy/networks/network.py
+++ b/src/tespy/networks/network.py
@@ -199,6 +199,7 @@ class Network:
         self.user_defined_eq = {}
         # bus dictionary
         self.busses = {}
+        self.subsystems = {}
         # results and specification dictionary
         self.results = {}
         self.specifications = {}
@@ -355,7 +356,7 @@ class Network:
             logger.error(msg)
             raise KeyError(msg)
 
-    def add_subsys(self, *args):
+    def add_subsystems(self, *args):
         r"""
         Add one or more subsystems to the network.
 
@@ -363,12 +364,60 @@ class Network:
         ----------
         c : tespy.components.subsystem.Subsystem
             The subsystem to be added to the network, subsystem objects si
-            :code:`network.add_subsys(s1, s2, s3, ...)`.
+            :code:`network.add_subsystems(s1, s2, s3, ...)`.
         """
-        for subsys in args:
-            for c in subsys.conns.values():
+        for subsystem in args:
+            if subsystem.label in self.subsystems:
+                msg = (
+                    'There is already a subsystem with the label '
+                    f'{subsystem.label}. The labels must be unique!'
+                )
+                logger.error(msg)
+                raise ValueError(msg)
+
+            self.subsystems[subsystem.label] = subsystem
+
+            for c in subsystem.conns.values():
                 self.add_conns(c)
 
+    def del_subsystems(self, *args):
+        r"""
+        Delete one or more subsystems from the network.
+
+        Parameters
+        ----------
+        c : tespy.components.subsystem.Subsystem
+            The subsystem to be deleted from the network, subsystem objects si
+            :code:`network.del_subsystems(s1, s2, s3, ...)`.
+        """
+        for subsystem in args:
+            if subsystem.label in self.subsystems:
+                for c in subsystem.conns.values():
+                    self.del_conns(c)
+
+            del self.subsystems[subsystem.label]
+
+    def get_subsystem(self, label):
+        r"""
+        Get Subsystem via label.
+
+        Parameters
+        ----------
+        label : str
+            Label of the Subsystem object.
+
+        Returns
+        -------
+        tespy.components.subsystem.Subsystem
+            Subsystem objectt with specified label, None if no Subsystem of
+            the network has this label.
+        """
+        try:
+            return self.subsystems[label]
+        except KeyError:
+            logger.warning(f"Subsystem with label {label} not found.")
+            return None
+
     def get_conn(self, label):
         r"""
         Get Connection via label.
@@ -2550,7 +2599,7 @@ class Network:
 
             b.P.val = float(self.results[b.label]['bus value'].sum())
 
-    def print_results(self, colored=True, colors=None, print_results=True):
+    def print_results(self, colored=True, colors=None, print_results=True, subsystem=None):
         r"""Print the calculations results to prompt."""
         # Define colors for highlighting values in result table
         if colors is None:
@@ -2572,16 +2621,34 @@ class Network:
             )
             raise hlp.TESPyNetworkError(msg)
 
+        result += self._print_components(colored, coloring, subsystem)
+        result += self._print_connections(colored, coloring, subsystem)
+        result += self._print_buses(colored, coloring, subsystem)
+
+        if len(str(result)) > 0:
+            logger.result(result)
+            if print_results:
+                print(result)
+        return
+
+    def _print_components(self, colored, coloring, subsystem) -> str:
+        result = ""
         for cp in self.comps['comp_type'].unique():
             df = self.results[cp].copy()
-
             # are there any parameters to print?
             if df.size > 0:
+                if subsystem is not None:
+                    component_labels = [
+                        c.label for c in subsystem.comps.values()
+                        if c.label in df.index
+                    ]
+                    df = df.loc[component_labels]
+
                 cols = df.columns
                 if len(cols) > 0:
                     for col in cols:
                         df[col] = df.apply(
-                            self.print_components, axis=1,
+                            self._color_component_prints, axis=1,
                             args=(col, colored, coloring))
 
                     df.dropna(how='all', inplace=True)
@@ -2595,9 +2662,20 @@ class Network:
                                 floatfmt='.2e'
                             )
                         )
+        return result
+
+    def _print_connections(self, colored, coloring, subsystem) -> str:
+        result = ""
 
         # connection properties
-        df = self.results['Connection'].loc[:, ['m', 'p', 'h', 'T', 'x', 'phase']].copy()
+        df = self.results['Connection'].loc[
+            :, ['m', 'p', 'h', 'T', 'x', 'phase']
+        ].copy()
+
+        if subsystem is not None:
+            connection_labels = [c.label for c in subsystem.conns.values()]
+            df = df.loc[connection_labels]
+
         df = df.astype(str)
         for c in df.index:
             if not self.get_conn(c).printout:
@@ -2607,44 +2685,46 @@ class Network:
                 conn = self.get_conn(c)
                 for col in df.columns:
                     if conn.get_attr(col).is_set:
+                        value = conn.get_attr(col).val
                         df.loc[c, col] = (
-                            coloring['set'] + str(conn.get_attr(col).val) +
-                            coloring['end'])
+                            f"{coloring['set']}{value}{coloring['end']}"
+                        )
 
         if len(df) > 0:
             result += ('\n##### RESULTS (Connection) #####\n')
             result += (
                 tabulate(df, headers='keys', tablefmt='psql', floatfmt='.3e')
             )
+        return result
 
-        for b in self.busses.values():
-            if b.printout:
-                df = self.results[b.label].loc[
-                    :, ['component value', 'bus value', 'efficiency']
-                ].copy()
-                df.loc['total'] = df.sum()
-                df.loc['total', 'efficiency'] = np.nan
-                if colored:
-                    df["bus value"] = df["bus value"].astype(str)
-                    if b.P.is_set:
-                        df.loc['total', 'bus value'] = (
-                            coloring['set'] + str(df.loc['total', 'bus value']) +
-                            coloring['end']
+    def _print_buses(self, colored, coloring, subsystem) -> str:
+        result = ""
+        # bus printout only if not subsystem is passed
+        if subsystem is None:
+            for b in self.busses.values():
+                if b.printout:
+                    df = self.results[b.label].loc[
+                        :, ['component value', 'bus value', 'efficiency']
+                    ].copy()
+                    df.loc['total'] = df.sum()
+                    df.loc['total', 'efficiency'] = np.nan
+                    if colored:
+                        df["bus value"] = df["bus value"].astype(str)
+                        if b.P.is_set:
+                            value = df.loc['total', 'bus value']
+                            df.loc['total', 'bus value'] = (
+                                f"{coloring['set']}{value}{coloring['end']}"
+                            )
+                    result += f"\n##### RESULTS (Bus: {b.label}) #####\n"
+                    result += (
+                        tabulate(
+                            df, headers='keys', tablefmt='psql',
+                            floatfmt='.3e'
                         )
-                result += f"\n##### RESULTS (Bus: {b.label}) #####\n"
-                result += (
-                    tabulate(
-                        df, headers='keys', tablefmt='psql',
-                        floatfmt='.3e'
                     )
-                )
-        if len(str(result)) > 0:
-            logger.result(result)
-            if print_results:
-                print(result)
-        return
+        return result
 
-    def print_components(self, c, *args):
+    def _color_component_prints(self, c, *args):
         """
         Get the print values for the component data.
 
@@ -2677,11 +2757,11 @@ class Network:
             # else part
             if (val < comp.get_attr(param).min_val - ERR or
                     val > comp.get_attr(param).max_val + ERR ):
-                return f"{coloring['err']} {val} {coloring['end']}"
+                return f"{coloring['err']}{val}{coloring['end']}"
             if comp.get_attr(args[0]).is_var:
-                return f"{coloring['var']} {val} {coloring['end']}"
+                return f"{coloring['var']}{val}{coloring['end']}"
             if comp.get_attr(args[0]).is_set:
-                return f"{coloring['set']} {val} {coloring['end']}"
+                return f"{coloring['set']}{val}{coloring['end']}"
             return str(val)
         else:
             return np.nan
@@ -3075,7 +3155,7 @@ class Network:
         dump["Component"] = self._save_components()
         dump["Bus"] = self._save_busses()
 
-        dump = self._nested_dict_of_dataframes_to_dict(dump)
+        dump = hlp._nested_dict_of_dataframes_to_dict(dump)
 
         with open(json_file_path, "w") as f:
             json.dump(dump, f)
@@ -3101,54 +3181,7 @@ class Network:
         dump["Connection"] = self._save_connections()
         dump["Component"] = self._save_components()
         dump["Bus"] = self._save_busses()
-        self._nested_dict_of_dataframes_to_csv(dump, folder_path)
-
-    def _nested_dict_of_dataframes_to_csv(self, dictionary, basepath):
-        """Dump a nested dict with dataframes into a folder structrue
-
-        The upper level keys with subdictionaries are folder names, the lower
-        level keys (where a dataframe is the value) will be the names of the
-        csv files.
-
-        Parameters
-        ----------
-        dictionary : dict
-            Nested dictionary to write to filesystem.
-        basepath : str
-            path to dump data to
-        """
-        os.makedirs(basepath, exist_ok=True)
-        for key, value in dictionary.items():
-            if isinstance(value, dict):
-                basepath = os.path.join(basepath, key)
-                self._nested_dict_of_dataframes_to_csv(value, basepath)
-            else:
-                value.to_csv(os.path.join(basepath, f"{key}.csv"))
-
-    def _nested_dict_of_dataframes_to_dict(self, dictionary):
-        """Transpose a nested dict with dataframes in a json style dict
-
-        Parameters
-        ----------
-        dictionary : dict
-            Dictionary of dataframes
-
-        Returns
-        -------
-        dict
-            json style dictionary containing all data from the dataframes
-        """
-        for key, value in dictionary.items():
-            if isinstance(value, dict):
-                dictionary[key] = self._nested_dict_of_dataframes_to_dict(value)
-            else:
-                # Series to csv does not have orient
-                kwargs = {}
-                if isinstance(value, pd.DataFrame):
-                    kwargs = {"orient": "index"}
-                dictionary[key] = value.to_dict(**kwargs)
-
-        return dictionary
+        hlp._nested_dict_of_dataframes_to_filetree(dump, folder_path)
 
     def _save_connections(self):
         """Save the connection properties.
diff --git a/src/tespy/tools/helpers.py b/src/tespy/tools/helpers.py
index 93e7af77..69de5e17 100644
--- a/src/tespy/tools/helpers.py
+++ b/src/tespy/tools/helpers.py
@@ -15,6 +15,7 @@ from collections.abc import Mapping
 from copy import deepcopy
 
 import CoolProp.CoolProp as CP
+import pandas as pd
 
 from tespy import __datapath__
 from tespy.tools import logger
@@ -593,3 +594,51 @@ def extend_basic_path(subfolder):
     if not os.path.isdir(extended_path):
         os.mkdir(extended_path)
     return extended_path
+
+
+def _nested_dict_of_dataframes_to_dict(dictionary):
+    """Transpose a nested dict with dataframes or series in a json style dict
+
+    Parameters
+    ----------
+    dictionary : dict
+        Dictionary of dataframes
+
+    Returns
+    -------
+    dict
+        json style dictionary containing all data from the dataframes
+    """
+    for key, value in dictionary.items():
+        if isinstance(value, dict):
+            dictionary[key] = _nested_dict_of_dataframes_to_dict(value)
+        else:
+            # Otherwise this is assumed a series, then orient is not available
+            kwargs = {}
+            if isinstance(value, pd.DataFrame):
+                kwargs = {"orient": "index"}
+            dictionary[key] = value.to_dict(**kwargs)
+
+    return dictionary
+
+def _nested_dict_of_dataframes_to_filetree(dictionary, basepath):
+    """Dump a nested dict with dataframes into a folder structrue
+
+    The upper level keys with subdictionaries are folder names, the lower
+    level keys (where a dataframe is the value) will be the names of the
+    csv files.
+
+    Parameters
+    ----------
+    dictionary : dict
+        Nested dictionary to write to filesystem.
+    basepath : str
+        path to dump data to
+    """
+    os.makedirs(basepath, exist_ok=True)
+    for key, value in dictionary.items():
+        if isinstance(value, dict):
+            basepath = os.path.join(basepath, key)
+            _nested_dict_of_dataframes_to_filetree(value, basepath)
+        else:
+            value.to_csv(os.path.join(basepath, f"{key}.csv"))


Test patch:
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 10e721de..165e6df0 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -135,14 +135,12 @@ def test_get_attr_errors():
     pipeline = Pipe('pipeline')
     conn = Connection(comb, 'out1', pipeline, 'in1')
     mybus = Bus('mybus')
-    sub = Subsystem('MySub')
 
     get_attr_KeyError(comb, 'wow')
     get_attr_KeyError(conn, 'key')
     get_attr_KeyError(mybus, 'components')
     get_attr_KeyError(nw, 'missing')
     get_attr_KeyError(Ref(conn, 1, 0), 'comp')
-    get_attr_KeyError(sub, 'test')
     get_attr_KeyError(CharLine(), 'test')
     get_attr_KeyError(DataContainer(), 'somekey')
     get_attr_KeyError(CharMap(), 'Stuff')
@@ -341,6 +339,11 @@ def test_subsys_label_forbidden():
     with raises(ValueError):
         Subsystem('label;')
 
+
+def test_subsys_no_create_network():
+    with raises(NotImplementedError):
+        Subsystem("bare subsystem")
+
 ##############################################################################
 # turbine