Writing components


This book has moved to the FINAM documentation. You are viewing an outdated version!


This chapter provides a step-by-step guide to implement a component with time (e.g. a model) in pure Python. For writing Python bindings for other languages, see Python bindings.

Completing the chapter will result in a simple component called DummyModel. We will build up the component step by step, accompanied by some test code. Finally, it will have two input slots and one output slot, and will calculate the sum of its inputs.

The component will have internal time stepping, like a simulation model would have. For implementing components without internal time, see chapter Components without time step.

It is assumed that you have FINAM installed, as well as pytest.

Set up a Python project

Create the following project structure:

- dummy_model/
   +- src/

We call dummy_model the project directory from here on.

Implement TimeComponent

The class TimeComponent provides an abstract implementation of the interface ITimeComponent to make implementation easier. Start by extending TimeComponent in a class we call DummyModel in src/dummy_model.py.

import finam as fm


class DummyModel(fm.TimeComponent):
    pass

However, we want to test our implementation while building up, so extend the file to the following content:

import finam as fm
import unittest                                                      # <--


class DummyModel(fm.TimeComponent):
    pass


class TestDummy(unittest.TestCase):                                  # <--
    def test_dummy_model(self):                                      # <--
        model = DummyModel()                                         # <--
        self.assertTrue(isinstance(model, DummyModel))               # <--

In your project directory run the following to test it:

$ python -m pytest -s src/dummy_model.py

Constructor

The component needs a constructor which calls the super class constructor.

import finam as fm
import unittest
from datetime import datetime                                        # <--


class DummyModel(fm.TimeComponent):

    def __init__(self, start):                                       # <--
        super().__init__()                                           # <--
        self.time = start


class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        model = DummyModel(start=datetime(2000, 1, 1))
        self.assertEqual(model.status, fm.ComponentStatus.CREATED)   # <--
        self.assertEqual(model.time, datetime(2000, 1, 1))           # <--

The property status is provided by TimeComponent, as are inputs and outputs, which are initialized with defaults. We will manipulate them later.

TimeComponent's time property must be initialized with a datetime object.

The constructor is also the place to define class variables required by the component. We want our component to have a user-defined time step, so we add it here:

import finam as fm
import unittest
from datetime import datetime, timedelta


class DummyModel(fm.TimeComponent):

    def __init__(self, start, step):                                 # <--
        super().__init__()
        self._step = step                                            # <--
        self.time = start


class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        model = DummyModel(start=datetime(2000, 1, 1),               # <--
                           step=timedelta(days=7))                   # <--
        self.assertEqual(model.status, fm.ComponentStatus.CREATED)
        self.assertEqual(model.time, datetime(2000, 1, 1))
        self.assertEqual(model._step, timedelta(days=7))             # <--

Run the test again to check everything is working.

Next, we need to implement or override some methods of TimeComponent

Initialize

In _initialize(), we define the component's input and output slots. It is called internally by the initialize() method.

(We will shorten previously completed parts and imports from now on.)

import finam as fm
import unittest
from datetime import datetime, timedelta


class DummyModel(fm.TimeComponent):

    def __init__(self, start, step):
        # ...

    def _initialize(self):                                             # <--
        self.inputs.add(name="A", grid=fm.NoGrid())                    # <--
        self.inputs.add(name="B", grid=fm.NoGrid())                    # <--
        self.outputs.add(name="Sum", grid=fm.NoGrid())                 # <--

        self.create_connector()                                        # <--


class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        model = DummyModel(start=datetime(2000, 1, 1),
                           step=timedelta(days=7))
        # ...

        model.initialize()
        self.assertEqual(model.status, fm.ComponentStatus.INITIALIZED)  # <--
        self.assertEqual(len(model.inputs), 2)                          # <--
        self.assertEqual(len(model.outputs), 1)                         # <--

Note that inputs and outputs are added with a name and a grid (or grid specification). They can later be accessed by the name, like self.inputs["A"].

The grid specification defines what inputs expect to receive, or what outputs provide. Here, we set it to NoGrid(), as we want to handle scalars only. In most real use cases, however, grid will be a grid specification like rectilinear or unstructured grids. See chapter Data types for more details.

In the last line, we call create_connector(), which sets up an internal helper that manages the initial exchange of data and metadata. For details and possible arguments, see chapter The Connect Phase ™.

Connect and validate

For the coupling to work, it is necessary that every component populates its outputs with initial values. This is done in _connect().

After this connection phase, models can validate their state in _validate(). We do nothing there.

# imports...


class DummyModel(fm.TimeComponent):

    def __init__(self, step):
        # ...

    def _initialize(self):
        # ...

    def _connect(self):                                                      # <--
        self.try_connect(time=self.time, push_data={"Sum": 0})               # <--
        
    def _validate(self):                                                     # <--
        pass                                                                 # <--

In _connect(), we call try_connect with the component's time (it's starting time), and a dictionary of data to push for each input. For more complex use cases like pulling data, see chapter The Connect Phase ™.

For the tests, we need to set up a real coupling from here on, as the component's inputs require connections in this phase.

class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        # our model
        model = DummyModel(start=datetime(2000, 1, 1),
                           step=timedelta(days=7))
        
        # a component to produce inputs, details not important
        generator = fm.modules.generators.CallbackGenerator(
            callbacks={
                "A": (lambda t: t.day, fm.Info(time=None, grid=fm.NoGrid())),
                "B": (lambda t: t.day * 2, fm.Info(time=None, grid=fm.NoGrid()))
            },
            start=datetime(2000, 1, 1),
            step=timedelta(days=7)
        )
        
        # a component to consume output, details not important
        consumer = fm.modules.debug.DebugConsumer(
            inputs={"Sum": fm.Info(grid=fm.NoGrid())},
            start=datetime(2000, 1, 1),
            step=timedelta(days=7)
        )
        
        # set up a composition
        composition = fm.Composition([model, generator, consumer],
                                     log_level="DEBUG")
        composition.initialize()
        
        # connect components
        generator.outputs["A"] >> model.inputs["A"]
        generator.outputs["B"] >> model.inputs["B"]

        model.outputs["Sum"] >> consumer.inputs["Sum"]
        
        # run the connection/exchange phase
        composition.connect()

        self.assertEqual(consumer.data, {"Sum": 0})

Here, we set up a complete coupling using a CallbackGenerator as source. A DebugConsumer is used as a sink to force the data flow and to allow us to inspect the result.

Update

Method _update() is where the actual work happens. It is called every time the scheduler decides that the component is on turn to make an update.

In _update, we get the component's input data, do a "model step", increment the time, and push results to the output slot.

# imports...


class DummyModel(fm.TimeComponent):

    def __init__(self, step):
        # ...

    def _initialize(self):
        # ...

    def _connect(self):
        # ...

    def _validate(self):
        # ...

    def _update(self):
        a = self.inputs["A"].pull_data(self.time)
        b = self.inputs["B"].pull_data(self.time)
        
        result = a + b
        
        # We need to unwrap the data here, as the push time will not equal the pull time.
        # This would result in conflicting timestamps in the internal checks
        result = fm.data.strip_data(result)

        self._time += self._step

        self.outputs["Sum"].push_data(result, self.time)


class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        # ...
    
        composition.run(t_max=datetime(2000, 12, 31))

The test should fail, as we still need to implement the _finalize() method.

Finalize

In method _finalize, the component can do any cleanup required at the end of the coupled run, like closing streams or writing final output data to disk.

We do nothing special here.

# imports...


class DummyModel(TimeComponent):

    def __init__(self, step):
        # ...

    def _initialize(self):
        # ...

    def _connect(self):
        # ...

    def _validate(self):
        # ...

    def _update(self):
        # ...

    def _finalize(self):
        pass

Final code

Here is the final code of the completed component.

import unittest
from datetime import datetime, timedelta

import finam as fm


class DummyModel(fm.TimeComponent):
    def __init__(self, start, step):  # <--
        super().__init__()
        self._step = step  # <--
        self.time = start

    def _initialize(self):  # <--
        self.inputs.add(name="A", grid=fm.NoGrid())  # <--
        self.inputs.add(name="B", grid=fm.NoGrid())  # <--
        self.outputs.add(name="Sum", grid=fm.NoGrid())  # <--

        self.create_connector()  # <--

    def _connect(self):  # <--
        self.try_connect(time=self.time, push_data={"Sum": 0})  # <--

    def _validate(self):  # <--
        pass

    def _update(self):
        a = self.inputs["A"].pull_data(self.time)
        b = self.inputs["B"].pull_data(self.time)

        result = a + b

        # We need to unwrap the data here, as the push time will not equal the pull time.
        # This would result in conflicting timestamps in the internal checks
        result = fm.data.strip_data(result)

        self._time += self._step

        self.outputs["Sum"].push_data(result, self.time)

    def _finalize(self):
        pass


class TestDummy(unittest.TestCase):
    def test_dummy_model(self):
        model = DummyModel(start=datetime(2000, 1, 1), step=timedelta(days=7))
        generator = fm.modules.generators.CallbackGenerator(
            callbacks={
                "A": (lambda t: t.day, fm.Info(time=None, grid=fm.NoGrid())),
                "B": (lambda t: t.day * 2, fm.Info(time=None, grid=fm.NoGrid())),
            },
            start=datetime(2000, 1, 1),
            step=timedelta(days=7),
        )
        consumer = fm.modules.debug.DebugConsumer(
            inputs={"Sum": fm.Info(grid=fm.NoGrid())},
            start=datetime(2000, 1, 1),
            step=timedelta(days=7),
        )
        composition = fm.Composition([model, generator, consumer], log_level="DEBUG")
        composition.initialize()

        generator.outputs["A"] >> model.inputs["A"]
        generator.outputs["B"] >> model.inputs["B"]

        model.outputs["Sum"] >> consumer.inputs["Sum"]

        composition.connect()

        self.assertEqual(consumer.data, {"Sum": 0})

        composition.run(t_max=datetime(2000, 12, 31))