diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2e411116766596d90f77e339f03684449780d9ae..a8671ba0d31b0b7bbd1de5da559cb1127a2fa1c3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -34,6 +34,7 @@ include:
   # - local: '/src/opticalattackmanager/.gitlab-ci.yml'
   - local: '/src/ztp/.gitlab-ci.yml'
   - local: '/src/policy/.gitlab-ci.yml'
+  - local: '/src/forecaster/.gitlab-ci.yml'
   #- local: '/src/webui/.gitlab-ci.yml'
   #- local: '/src/l3_distributedattackdetector/.gitlab-ci.yml'
   #- local: '/src/l3_centralizedattackdetector/.gitlab-ci.yml'
diff --git a/manifests/forecasterservice.yaml b/manifests/forecasterservice.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..55d4add88f6fc507e9a4271cb40b20c4742c5bc7
--- /dev/null
+++ b/manifests/forecasterservice.yaml
@@ -0,0 +1,101 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: forecasterservice
+spec:
+  selector:
+    matchLabels:
+      app: forecasterservice
+  #replicas: 1
+  template:
+    metadata:
+      labels:
+        app: forecasterservice
+    spec:
+      terminationGracePeriodSeconds: 5
+      containers:
+      - name: server
+        image: labs.etsi.org:5050/tfs/controller/forecaster:latest
+        imagePullPolicy: Always
+        ports:
+        - containerPort: 10040
+        - containerPort: 9192
+        env:
+        - name: LOG_LEVEL
+          value: "INFO"
+        - name: FORECAST_TO_HISTORY_RATIO
+          value: "10"
+        startupProbe:
+          exec:
+            command: ["/bin/grpc_health_probe", "-addr=:10040"]
+          failureThreshold: 30
+          periodSeconds: 1
+        readinessProbe:
+          exec:
+            command: ["/bin/grpc_health_probe", "-addr=:10040"]
+        livenessProbe:
+          exec:
+            command: ["/bin/grpc_health_probe", "-addr=:10040"]
+        resources:
+          requests:
+            cpu: 250m
+            memory: 128Mi
+          limits:
+            cpu: 1000m
+            memory: 1024Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: forecasterservice
+  labels:
+    app: forecasterservice
+spec:
+  type: ClusterIP
+  selector:
+    app: forecasterservice
+  ports:
+  - name: grpc
+    protocol: TCP
+    port: 10040
+    targetPort: 10040
+  - name: metrics
+    protocol: TCP
+    port: 9192
+    targetPort: 9192
+---
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+  name: forecasterservice-hpa
+spec:
+  scaleTargetRef:
+    apiVersion: apps/v1
+    kind: Deployment
+    name: forecasterservice
+  minReplicas: 1
+  maxReplicas: 20
+  metrics:
+  - type: Resource
+    resource:
+      name: cpu
+      target:
+        type: Utilization
+        averageUtilization: 80
+  #behavior:
+  #  scaleDown:
+  #    stabilizationWindowSeconds: 30
diff --git a/manifests/pathcompservice.yaml b/manifests/pathcompservice.yaml
index c85922d961ecc7b99e8fa2476b5e61db7ed52a9d..87d907a728d0b689dcedde730fad7a2e886a6659 100644
--- a/manifests/pathcompservice.yaml
+++ b/manifests/pathcompservice.yaml
@@ -37,6 +37,8 @@ spec:
         env:
         - name: LOG_LEVEL
           value: "INFO"
+        - name: ENABLE_FORECASTER
+          value: "YES"
         readinessProbe:
           exec:
             command: ["/bin/grpc_health_probe", "-addr=:10020"]
diff --git a/my_deploy.sh b/my_deploy.sh
index 99e5d40597f458db8546d28b2ef14f0b0d3358e4..525cb20ac3780fbb9f6257d6ed735f580b5fd221 100755
--- a/my_deploy.sh
+++ b/my_deploy.sh
@@ -40,6 +40,9 @@ export TFS_COMPONENTS="context device pathcomp service slice compute webui load_
 # Uncomment to activate TE
 #export TFS_COMPONENTS="${TFS_COMPONENTS} te"
 
+# Uncomment to activate Forecaster
+#export TFS_COMPONENTS="${TFS_COMPONENTS} forecaster"
+
 # Set the tag you want to use for your images.
 export TFS_IMAGE_TAG="dev"
 
diff --git a/proto/context.proto b/proto/context.proto
index 22e11bc68b840115a19551958ac322acb71fb9a4..3ccc13ab199ae7587b0c99340c85524f16e86431 100644
--- a/proto/context.proto
+++ b/proto/context.proto
@@ -236,10 +236,16 @@ message LinkId {
   Uuid link_uuid = 1;
 }
 
+message LinkAttributes {
+  float total_capacity_gbps = 1;
+  float used_capacity_gbps  = 2;
+}
+
 message Link {
   LinkId link_id = 1;
   string name = 2;
   repeated EndPointId link_endpoint_ids = 3;
+  LinkAttributes attributes = 4;
 }
 
 message LinkIdList {
diff --git a/proto/forecaster.proto b/proto/forecaster.proto
index 5a4403b01c7f85d6d5b33548d0eaf463e39558cc..45cf6967c40831bec5a073b7fabbe25b6b966268 100644
--- a/proto/forecaster.proto
+++ b/proto/forecaster.proto
@@ -18,28 +18,27 @@ package forecaster;
 import "context.proto";
 
 service ForecasterService {
-  rpc GetForecastOfTopology (context.TopologyId) returns (Forecast) {}
-  rpc GetForecastOfLink(context.LinkId) returns (Forecast) {}
-  rpc CheckService (context.ServiceId) returns (ForecastPrediction) {}
+  rpc ForecastLinkCapacity    (ForecastLinkCapacityRequest    ) returns (ForecastLinkCapacityReply    ) {}
+  rpc ForecastTopologyCapacity(ForecastTopologyCapacityRequest) returns (ForecastTopologyCapacityReply) {}
 }
 
-message SingleForecast {
-  context.Timestamp timestamp= 1;
-  double value = 2;    
+message ForecastLinkCapacityRequest {
+  context.LinkId link_id                 = 1;
+  float          forecast_window_seconds = 2;
 }
 
-message Forecast {
-    oneof uuid {
-      context.TopologyId topologyId= 1;
-      context.LinkId linkId = 2;
-    }
-    repeated SingleForecast forecast = 3;
+message ForecastLinkCapacityReply {
+  context.LinkId link_id                     = 1;
+  float          total_capacity_gbps         = 2;
+  float          current_used_capacity_gbps  = 3;
+  float          forecast_used_capacity_gbps = 4;
 }
 
-enum AvailabilityPredictionEnum {
-    FORECASTED_AVAILABILITY = 0;
-    FORECASTED_UNAVAILABILITY = 1;
+message ForecastTopologyCapacityRequest {
+  context.TopologyId topology_id             = 1;
+  float              forecast_window_seconds = 2;
 }
-message ForecastPrediction {
-    AvailabilityPredictionEnum prediction = 1;
+
+message ForecastTopologyCapacityReply {
+  repeated ForecastLinkCapacityReply link_capacities = 1;
 }
diff --git a/proto/kpi_sample_types.proto b/proto/kpi_sample_types.proto
index 1ade4d69bf5a6c23d993cd37ed731eee10d7374e..5b234a4e35197557f41770984f7c8f6603672411 100644
--- a/proto/kpi_sample_types.proto
+++ b/proto/kpi_sample_types.proto
@@ -17,18 +17,26 @@ package kpi_sample_types;
 
 enum KpiSampleType {
     KPISAMPLETYPE_UNKNOWN                       = 0;
+
     KPISAMPLETYPE_PACKETS_TRANSMITTED           = 101;
     KPISAMPLETYPE_PACKETS_RECEIVED              = 102;
     KPISAMPLETYPE_PACKETS_DROPPED               = 103;
     KPISAMPLETYPE_BYTES_TRANSMITTED             = 201;
     KPISAMPLETYPE_BYTES_RECEIVED                = 202;
     KPISAMPLETYPE_BYTES_DROPPED                 = 203;
+
+    KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS      = 301;
+    KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS       = 302;
+
     KPISAMPLETYPE_ML_CONFIDENCE                 = 401;  //. can be used by both optical and L3 without any issue
+
     KPISAMPLETYPE_OPTICAL_SECURITY_STATUS       = 501;  //. can be used by both optical and L3 without any issue
+
     KPISAMPLETYPE_L3_UNIQUE_ATTACK_CONNS        = 601;
     KPISAMPLETYPE_L3_TOTAL_DROPPED_PACKTS       = 602;
     KPISAMPLETYPE_L3_UNIQUE_ATTACKERS           = 603;
     KPISAMPLETYPE_L3_UNIQUE_COMPROMISED_CLIENTS = 604;
     KPISAMPLETYPE_L3_SECURITY_STATUS_CRYPTO     = 605;
+
     KPISAMPLETYPE_SERVICE_LATENCY_MS            = 701;
 }
diff --git a/proto/monitoring.proto b/proto/monitoring.proto
index 3862973e056d6267d8defc68e77cbf3c8a10ebee..45ba48b0271c6e8890d7125ff44f62d2b6da6b58 100644
--- a/proto/monitoring.proto
+++ b/proto/monitoring.proto
@@ -49,6 +49,7 @@ message KpiDescriptor {
   context.ServiceId              service_id      = 7;
   context.SliceId                slice_id        = 8;
   context.ConnectionId           connection_id   = 9;
+  context.LinkId                 link_id         = 10;
 }
 
 message MonitorKpiRequest {
diff --git a/scripts/run_tests_locally-forecaster.sh b/scripts/run_tests_locally-forecaster.sh
new file mode 100755
index 0000000000000000000000000000000000000000..e5b9e3e7d249461d6421dd4050890d80757644ab
--- /dev/null
+++ b/scripts/run_tests_locally-forecaster.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+PROJECTDIR=`pwd`
+
+cd $PROJECTDIR/src
+RCFILE=$PROJECTDIR/coverage/.coveragerc
+
+# Run unitary tests and analyze coverage of code at same time
+# helpful pytest flags: --log-level=INFO -o log_cli=true --verbose --maxfail=1 --durations=0
+coverage run --rcfile=$RCFILE --append -m pytest --log-level=INFO --verbose \
+    forecaster/tests/test_unitary.py
diff --git a/scripts/show_logs_forecaster.sh b/scripts/show_logs_forecaster.sh
new file mode 100755
index 0000000000000000000000000000000000000000..6bb518fe5120db3620e5b25b3bb70b0483131ea3
--- /dev/null
+++ b/scripts/show_logs_forecaster.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+########################################################################################################################
+# Define your deployment settings here
+########################################################################################################################
+
+# If not already set, set the name of the Kubernetes namespace to deploy to.
+export TFS_K8S_NAMESPACE=${TFS_K8S_NAMESPACE:-"tfs"}
+
+########################################################################################################################
+# Automated steps start here
+########################################################################################################################
+
+kubectl --namespace $TFS_K8S_NAMESPACE logs deployment/forecasterservice -c server
diff --git a/src/common/Constants.py b/src/common/Constants.py
index 91d3116359ff53e10adba8f4e3d6e8bcc05c2abb..0507cb0caa0d80081d49cffaf281f2f758a25d27 100644
--- a/src/common/Constants.py
+++ b/src/common/Constants.py
@@ -57,6 +57,7 @@ class ServiceNameEnum(Enum):
     OPTICALATTACKMITIGATOR = 'opticalattackmitigator'
     CACHING                = 'caching'
     TE                     = 'te'
+    FORECASTER             = 'forecaster'
 
     # Used for test and debugging only
     DLT_GATEWAY    = 'dltgateway'
@@ -82,6 +83,7 @@ DEFAULT_SERVICE_GRPC_PORTS = {
     ServiceNameEnum.INTERDOMAIN            .value : 10010,
     ServiceNameEnum.PATHCOMP               .value : 10020,
     ServiceNameEnum.TE                     .value : 10030,
+    ServiceNameEnum.FORECASTER             .value : 10040,
 
     # Used for test and debugging only
     ServiceNameEnum.DLT_GATEWAY   .value : 50051,
diff --git a/src/common/tests/InMemoryObjectDatabase.py b/src/common/tests/InMemoryObjectDatabase.py
new file mode 100644
index 0000000000000000000000000000000000000000..21697a4355795775cc25112671c4e436fbbecb8c
--- /dev/null
+++ b/src/common/tests/InMemoryObjectDatabase.py
@@ -0,0 +1,65 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import grpc, logging
+from typing import Any, Dict, List, Set
+
+LOGGER = logging.getLogger(__name__)
+
+class InMemoryObjectDatabase:
+    def __init__(self) -> None:
+        self._database : Dict[str, Dict[str, Any]] = dict()
+
+    def _get_container(self, container_name : str) -> Dict[str, Any]:
+        return self._database.setdefault(container_name, {})
+
+    def get_entries(self, container_name : str) -> List[Any]:
+        container = self._get_container(container_name)
+        return [container[entry_uuid] for entry_uuid in sorted(container.keys())]
+
+    def has_entry(self, container_name : str, entry_uuid : str) -> Any:
+        LOGGER.debug('[has_entry] BEFORE database={:s}'.format(str(self._database)))
+        container = self._get_container(container_name)
+        return entry_uuid in container
+
+    def get_entry(self, container_name : str, entry_uuid : str, context : grpc.ServicerContext) -> Any:
+        LOGGER.debug('[get_entry] BEFORE database={:s}'.format(str(self._database)))
+        container = self._get_container(container_name)
+        if entry_uuid not in container:
+            context.abort(grpc.StatusCode.NOT_FOUND, str('{:s}({:s}) not found'.format(container_name, entry_uuid)))
+        return container[entry_uuid]
+
+    def set_entry(self, container_name : str, entry_uuid : str, entry : Any) -> Any:
+        container = self._get_container(container_name)
+        LOGGER.debug('[set_entry] BEFORE database={:s}'.format(str(self._database)))
+        container[entry_uuid] = entry
+        LOGGER.debug('[set_entry] AFTER database={:s}'.format(str(self._database)))
+        return entry
+
+    def del_entry(self, container_name : str, entry_uuid : str, context : grpc.ServicerContext) -> None:
+        container = self._get_container(container_name)
+        LOGGER.debug('[del_entry] BEFORE database={:s}'.format(str(self._database)))
+        if entry_uuid not in container:
+            context.abort(grpc.StatusCode.NOT_FOUND, str('{:s}({:s}) not found'.format(container_name, entry_uuid)))
+        del container[entry_uuid]
+        LOGGER.debug('[del_entry] AFTER database={:s}'.format(str(self._database)))
+
+    def select_entries(self, container_name : str, entry_uuids : Set[str]) -> List[Any]:
+        if len(entry_uuids) == 0: return self.get_entries(container_name)
+        container = self._get_container(container_name)
+        return [
+            container[entry_uuid]
+            for entry_uuid in sorted(container.keys())
+            if entry_uuid in entry_uuids
+        ]
diff --git a/src/common/tests/InMemoryTimeSeriesDatabase.py b/src/common/tests/InMemoryTimeSeriesDatabase.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c4c86da88bfb3ca99ecd92e5baab7244bea414c
--- /dev/null
+++ b/src/common/tests/InMemoryTimeSeriesDatabase.py
@@ -0,0 +1,41 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, pandas
+from typing import List, Optional
+
+LOGGER = logging.getLogger(__name__)
+
+class InMemoryTimeSeriesDatabase:
+    def __init__(self) -> None:
+        self._data = pandas.DataFrame(columns=['timestamp', 'kpi_uuid', 'value'])
+
+    def filter(
+        self, kpi_uuids : List[str] = [], start_timestamp : Optional[float] = None,
+        end_timestamp : Optional[float] = None
+    ) -> pandas.DataFrame:
+        data = self._data
+
+        if len(kpi_uuids) > 0:
+            data = data[data.kpi_uuid.isin(kpi_uuids)]
+
+        if start_timestamp is not None:
+            start_datetime = pandas.to_datetime(start_timestamp, unit='s')
+            data = data[data.timestamp >= start_datetime]
+
+        if end_timestamp is not None:
+            end_datetime = pandas.to_datetime(end_timestamp, unit='s')
+            data = data[data.timestamp <= end_datetime]
+
+        return data
diff --git a/src/common/tests/MockServicerImpl_Context.py b/src/common/tests/MockServicerImpl_Context.py
index e5d8ea76d25a81303df5a8e14073e1dcdc103ef0..55f87b7b0c03a7ae563dc10bd5e4964a07317c21 100644
--- a/src/common/tests/MockServicerImpl_Context.py
+++ b/src/common/tests/MockServicerImpl_Context.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 import grpc, json, logging
-from typing import Any, Dict, Iterator, List, Set
+from typing import Any, Dict, Iterator, Set, Tuple
 from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME
 from common.proto.context_pb2 import (
     Connection, ConnectionEvent, ConnectionId, ConnectionIdList, ConnectionList,
@@ -25,226 +25,221 @@ from common.proto.context_pb2 import (
     Slice, SliceEvent, SliceFilter, SliceId, SliceIdList, SliceList,
     Topology, TopologyDetails, TopologyEvent, TopologyId, TopologyIdList, TopologyList)
 from common.proto.context_pb2_grpc import ContextServiceServicer
-from common.tests.MockMessageBroker import (
+from common.tools.grpc.Tools import grpc_message_to_json, grpc_message_to_json_string
+from .InMemoryObjectDatabase import InMemoryObjectDatabase
+from .MockMessageBroker import (
     TOPIC_CONNECTION, TOPIC_CONTEXT, TOPIC_DEVICE, TOPIC_LINK, TOPIC_SERVICE, TOPIC_SLICE, TOPIC_TOPOLOGY,
     MockMessageBroker, notify_event)
-from common.tools.grpc.Tools import grpc_message_to_json, grpc_message_to_json_string
 
 LOGGER = logging.getLogger(__name__)
 
-def get_container(database : Dict[str, Dict[str, Any]], container_name : str) -> Dict[str, Any]:
-    return database.setdefault(container_name, {})
-
-def get_entries(database : Dict[str, Dict[str, Any]], container_name : str) -> List[Any]:
-    container = get_container(database, container_name)
-    return [container[entry_uuid] for entry_uuid in sorted(container.keys())]
-
-def has_entry(database : Dict[str, Dict[str, Any]], container_name : str, entry_uuid : str) -> Any:
-    LOGGER.debug('[has_entry] BEFORE database={:s}'.format(str(database)))
-    container = get_container(database, container_name)
-    return entry_uuid in container
-
-def get_entry(
-    context : grpc.ServicerContext, database : Dict[str, Dict[str, Any]], container_name : str, entry_uuid : str
-) -> Any:
-    LOGGER.debug('[get_entry] BEFORE database={:s}'.format(str(database)))
-    container = get_container(database, container_name)
-    if entry_uuid not in container:
-        context.abort(grpc.StatusCode.NOT_FOUND, str('{:s}({:s}) not found'.format(container_name, entry_uuid)))
-    return container[entry_uuid]
-
-def set_entry(database : Dict[str, Dict[str, Any]], container_name : str, entry_uuid : str, entry : Any) -> Any:
-    container = get_container(database, container_name)
-    LOGGER.debug('[set_entry] BEFORE database={:s}'.format(str(database)))
-    container[entry_uuid] = entry
-    LOGGER.debug('[set_entry] AFTER database={:s}'.format(str(database)))
-    return entry
-
-def del_entry(
-    context : grpc.ServicerContext, database : Dict[str, Dict[str, Any]], container_name : str, entry_uuid : str
-) -> Any:
-    container = get_container(database, container_name)
-    if entry_uuid not in container:
-        context.abort(grpc.StatusCode.NOT_FOUND, str('{:s}({:s}) not found'.format(container_name, entry_uuid)))
-    del container[entry_uuid]
-    return Empty()
-
-def select_entries(database : Dict[str, Dict[str, Any]], container_name : str, entry_uuids : Set[str]) -> List[Any]:
-    if len(entry_uuids) == 0: return get_entries(database, container_name)
-    container = get_container(database, container_name)
-    return [
-        container[entry_uuid]
-        for entry_uuid in sorted(container.keys())
-        if entry_uuid in entry_uuids
-    ]
-
 class MockServicerImpl_Context(ContextServiceServicer):
     def __init__(self):
-        LOGGER.info('[__init__] Creating Servicer...')
-        self.database : Dict[str, Dict[str, Any]] = {}
+        LOGGER.debug('[__init__] Creating Servicer...')
+        self.obj_db = InMemoryObjectDatabase()
         self.msg_broker = MockMessageBroker()
-        LOGGER.info('[__init__] Servicer Created')
+        LOGGER.debug('[__init__] Servicer Created')
 
     # ----- Common -----------------------------------------------------------------------------------------------------
 
-    def _set(self, request, container_name, entry_uuid, entry_id_field_name, topic_name):
-        exists = has_entry(self.database, container_name, entry_uuid)
-        entry = set_entry(self.database, container_name, entry_uuid, request)
+    def _set(self, request, container_name, entry_uuid, entry_id_field_name, topic_name) -> Tuple[Any, Any]:
+        exists = self.obj_db.has_entry(container_name, entry_uuid)
+        entry = self.obj_db.set_entry(container_name, entry_uuid, request)
         event_type = EventTypeEnum.EVENTTYPE_UPDATE if exists else EventTypeEnum.EVENTTYPE_CREATE
         entry_id = getattr(entry, entry_id_field_name)
         dict_entry_id = grpc_message_to_json(entry_id)
         notify_event(self.msg_broker, topic_name, event_type, {entry_id_field_name: dict_entry_id})
-        return entry_id
+        return entry_id, entry
 
-    def _del(self, request, container_name, entry_uuid, entry_id_field_name, topic_name, grpc_context):
-        empty = del_entry(grpc_context, self.database, container_name, entry_uuid)
+    def _del(self, request, container_name, entry_uuid, entry_id_field_name, topic_name, context) -> Empty:
+        self.obj_db.del_entry(container_name, entry_uuid, context)
         event_type = EventTypeEnum.EVENTTYPE_REMOVE
         dict_entry_id = grpc_message_to_json(request)
         notify_event(self.msg_broker, topic_name, event_type, {entry_id_field_name: dict_entry_id})
-        return empty
+        return Empty()
 
     # ----- Context ----------------------------------------------------------------------------------------------------
 
     def ListContextIds(self, request: Empty, context : grpc.ServicerContext) -> ContextIdList:
-        LOGGER.info('[ListContextIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = ContextIdList(context_ids=[context.context_id for context in get_entries(self.database, 'context')])
-        LOGGER.info('[ListContextIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListContextIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = ContextIdList(context_ids=[context.context_id for context in self.obj_db.get_entries('context')])
+        LOGGER.debug('[ListContextIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListContexts(self, request: Empty, context : grpc.ServicerContext) -> ContextList:
-        LOGGER.info('[ListContexts] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = ContextList(contexts=get_entries(self.database, 'context'))
-        LOGGER.info('[ListContexts] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListContexts] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = ContextList(contexts=self.obj_db.get_entries('context'))
+        LOGGER.debug('[ListContexts] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetContext(self, request: ContextId, context : grpc.ServicerContext) -> Context:
-        LOGGER.info('[GetContext] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = get_entry(context, self.database, 'context', request.context_uuid.uuid)
-        LOGGER.info('[GetContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[GetContext] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self.obj_db.get_entry('context', request.context_uuid.uuid, context)
+        LOGGER.debug('[GetContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetContext(self, request: Context, context : grpc.ServicerContext) -> ContextId:
-        LOGGER.info('[SetContext] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = self._set(request, 'context', request.context_id.context_uuid.uuid, 'context_id', TOPIC_CONTEXT)
-        LOGGER.info('[SetContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SetContext] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply,_ = self._set(request, 'context', request.context_id.context_uuid.uuid, 'context_id', TOPIC_CONTEXT)
+        LOGGER.debug('[SetContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveContext(self, request: ContextId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveContext] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[RemoveContext] request={:s}'.format(grpc_message_to_json_string(request)))
         reply = self._del(request, 'context', request.context_uuid.uuid, 'context_id', TOPIC_CONTEXT, context)
-        LOGGER.info('[RemoveContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveContext] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetContextEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[ContextEvent]:
-        LOGGER.info('[GetContextEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetContextEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_CONTEXT}): yield ContextEvent(**json.loads(message.content))
 
 
     # ----- Topology ---------------------------------------------------------------------------------------------------
 
     def ListTopologyIds(self, request: ContextId, context : grpc.ServicerContext) -> TopologyIdList:
-        LOGGER.info('[ListTopologyIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        topologies = get_entries(self.database, 'topology[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListTopologyIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        topologies = self.obj_db.get_entries('topology[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = TopologyIdList(topology_ids=[topology.topology_id for topology in topologies])
-        LOGGER.info('[ListTopologyIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListTopologyIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListTopologies(self, request: ContextId, context : grpc.ServicerContext) -> TopologyList:
-        LOGGER.info('[ListTopologies] request={:s}'.format(grpc_message_to_json_string(request)))
-        topologies = get_entries(self.database, 'topology[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListTopologies] request={:s}'.format(grpc_message_to_json_string(request)))
+        topologies = self.obj_db.get_entries('topology[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = TopologyList(topologies=[topology for topology in topologies])
-        LOGGER.info('[ListTopologies] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListTopologies] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetTopology(self, request: TopologyId, context : grpc.ServicerContext) -> Topology:
-        LOGGER.info('[GetTopology] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetTopology] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'topology[{:s}]'.format(str(request.context_id.context_uuid.uuid))
-        reply = get_entry(context, self.database, container_name, request.topology_uuid.uuid)
-        LOGGER.info('[GetTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply = self.obj_db.get_entry(container_name, request.topology_uuid.uuid, context)
+        LOGGER.debug('[GetTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetTopologyDetails(self, request : TopologyId, context : grpc.ServicerContext) -> TopologyDetails:
-        LOGGER.info('[GetTopologyDetails] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetTopologyDetails] request={:s}'.format(grpc_message_to_json_string(request)))
         context_uuid = request.context_id.context_uuid.uuid
         container_name = 'topology[{:s}]'.format(str(context_uuid))
         topology_uuid = request.topology_uuid.uuid
-        _reply = get_entry(context, self.database, container_name, topology_uuid)
+        _reply = self.obj_db.get_entry(container_name, topology_uuid, context)
         reply = TopologyDetails()
-        reply.topology_id.CopyFrom(_reply.topology_id)
+        reply.topology_id.CopyFrom(_reply.topology_id) # pylint: disable=no-member
         reply.name = _reply.name
         if context_uuid == DEFAULT_CONTEXT_NAME and topology_uuid == DEFAULT_TOPOLOGY_NAME:
-            for device in get_entries(self.database, 'device'): reply.devices.append(device)
-            for link in get_entries(self.database, 'link'): reply.links.append(link)
+            for device in self.obj_db.get_entries('device'): reply.devices.append(device)   # pylint: disable=no-member
+            for link   in self.obj_db.get_entries('link'  ): reply.links  .append(link  )   # pylint: disable=no-member
         else:
             # TODO: to be improved; Mock does not associate devices/links to topologies automatically
             for device_id in _reply.device_ids:
-                device = get_entry(context, self.database, 'device', device_id.device_uuid.uuid)
-                reply.devices.append(device)
+                device = self.obj_db.get_entry('device', device_id.device_uuid.uuid, context)
+                reply.devices.append(device) # pylint: disable=no-member
             for link_id in _reply.link_ids:
-                link = get_entry(context, self.database, 'link', link_id.link_uuid.uuid)
-                reply.links.append(link)
-        LOGGER.info('[GetTopologyDetails] reply={:s}'.format(grpc_message_to_json_string(reply)))
+                link = self.obj_db.get_entry('link', link_id.link_uuid.uuid, context)
+                reply.links.append(link) # pylint: disable=no-member
+        LOGGER.debug('[GetTopologyDetails] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetTopology(self, request: Topology, context : grpc.ServicerContext) -> TopologyId:
-        LOGGER.info('[SetTopology] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SetTopology] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'topology[{:s}]'.format(str(request.topology_id.context_id.context_uuid.uuid))
         topology_uuid = request.topology_id.topology_uuid.uuid
-        reply = self._set(request, container_name, topology_uuid, 'topology_id', TOPIC_TOPOLOGY)
-        LOGGER.info('[SetTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply,_ = self._set(request, container_name, topology_uuid, 'topology_id', TOPIC_TOPOLOGY)
+        LOGGER.debug('[SetTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveTopology(self, request: TopologyId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveTopology] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[RemoveTopology] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'topology[{:s}]'.format(str(request.context_id.context_uuid.uuid))
         topology_uuid = request.topology_uuid.uuid
         reply = self._del(request, container_name, topology_uuid, 'topology_id', TOPIC_TOPOLOGY, context)
-        LOGGER.info('[RemoveTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveTopology] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetTopologyEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[TopologyEvent]:
-        LOGGER.info('[GetTopologyEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetTopologyEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_TOPOLOGY}): yield TopologyEvent(**json.loads(message.content))
 
 
     # ----- Device -----------------------------------------------------------------------------------------------------
 
     def ListDeviceIds(self, request: Empty, context : grpc.ServicerContext) -> DeviceIdList:
-        LOGGER.info('[ListDeviceIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = DeviceIdList(device_ids=[device.device_id for device in get_entries(self.database, 'device')])
-        LOGGER.info('[ListDeviceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListDeviceIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = DeviceIdList(device_ids=[device.device_id for device in self.obj_db.get_entries('device')])
+        LOGGER.debug('[ListDeviceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListDevices(self, request: Empty, context : grpc.ServicerContext) -> DeviceList:
-        LOGGER.info('[ListDevices] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = DeviceList(devices=get_entries(self.database, 'device'))
-        LOGGER.info('[ListDevices] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListDevices] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = DeviceList(devices=self.obj_db.get_entries('device'))
+        LOGGER.debug('[ListDevices] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetDevice(self, request: DeviceId, context : grpc.ServicerContext) -> Device:
-        LOGGER.info('[GetDevice] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = get_entry(context, self.database, 'device', request.device_uuid.uuid)
-        LOGGER.info('[GetDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[GetDevice] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self.obj_db.get_entry('device', request.device_uuid.uuid, context)
+        LOGGER.debug('[GetDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetDevice(self, request: Context, context : grpc.ServicerContext) -> DeviceId:
-        LOGGER.info('[SetDevice] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = self._set(request, 'device', request.device_id.device_uuid.uuid, 'device_id', TOPIC_DEVICE)
-        LOGGER.info('[SetDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SetDevice] request={:s}'.format(grpc_message_to_json_string(request)))
+        device_uuid = request.device_id.device_uuid.uuid
+        reply, device = self._set(request, 'device', device_uuid, 'device_id', TOPIC_DEVICE)
+
+        context_topology_uuids : Set[Tuple[str, str]] = set()
+        context_topology_uuids.add((DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME))
+        for endpoint in device.device_endpoints:
+            endpoint_context_uuid = endpoint.endpoint_id.topology_id.context_id.context_uuid.uuid
+            if len(endpoint_context_uuid) == 0: endpoint_context_uuid = DEFAULT_CONTEXT_NAME
+            endpoint_topology_uuid = endpoint.endpoint_id.topology_id.topology_uuid.uuid
+            if len(endpoint_topology_uuid) == 0: endpoint_topology_uuid = DEFAULT_TOPOLOGY_NAME
+            context_topology_uuids.add((endpoint_context_uuid, endpoint_topology_uuid))
+
+        for context_uuid,topology_uuid in context_topology_uuids:
+            container_name = 'topology[{:s}]'.format(str(context_uuid))
+            topology = self.obj_db.get_entry(container_name, topology_uuid, context)
+            for _device_id in topology.device_ids:
+                if _device_id.device_uuid.uuid == device_uuid: break
+            else:
+                # device not found, add it
+                topology.device_ids.add().device_uuid.uuid = device_uuid
+
+        LOGGER.debug('[SetDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveDevice(self, request: DeviceId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveDevice] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = self._del(request, 'device', request.device_uuid.uuid, 'device_id', TOPIC_DEVICE, context)
-        LOGGER.info('[RemoveDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveDevice] request={:s}'.format(grpc_message_to_json_string(request)))
+        device_uuid = request.device_uuid.uuid
+        device = self.obj_db.get_entry('device', device_uuid, context)
+        reply = self._del(request, 'device', device_uuid, 'device_id', TOPIC_DEVICE, context)
+
+        context_topology_uuids : Set[Tuple[str, str]] = set()
+        context_topology_uuids.add((DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME))
+        for endpoint in device.device_endpoints:
+            endpoint_context_uuid = endpoint.endpoint_id.topology_id.context_id.context_uuid.uuid
+            if len(endpoint_context_uuid) == 0: endpoint_context_uuid = DEFAULT_CONTEXT_NAME
+            endpoint_topology_uuid = endpoint.endpoint_id.topology_id.topology_uuid.uuid
+            if len(endpoint_topology_uuid) == 0: endpoint_topology_uuid = DEFAULT_TOPOLOGY_NAME
+            context_topology_uuids.add((endpoint_context_uuid, endpoint_topology_uuid))
+
+        for context_uuid,topology_uuid in context_topology_uuids:
+            container_name = 'topology[{:s}]'.format(str(context_uuid))
+            topology = self.obj_db.get_entry(container_name, topology_uuid, context)
+            for device_id in topology.device_ids:
+                if device_id.device_uuid.uuid == device_uuid:
+                    topology.device_ids.remove(device_id)
+                    break
+
+        LOGGER.debug('[RemoveDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetDeviceEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[DeviceEvent]:
-        LOGGER.info('[GetDeviceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetDeviceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_DEVICE}): yield DeviceEvent(**json.loads(message.content))
 
     def SelectDevice(self, request : DeviceFilter, context : grpc.ServicerContext) -> DeviceList:
-        LOGGER.info('[SelectDevice] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SelectDevice] request={:s}'.format(grpc_message_to_json_string(request)))
         container_entry_uuids : Dict[str, Set[str]] = {}
         container_name = 'device'
         for device_id in request.device_ids.device_ids:
@@ -258,7 +253,7 @@ class MockServicerImpl_Context(ContextServiceServicer):
         devices = list()
         for container_name in sorted(container_entry_uuids.keys()):
              entry_uuids = container_entry_uuids[container_name]
-        for device in select_entries(self.database, container_name, entry_uuids):
+        for device in self.obj_db.select_entries(container_name, entry_uuids):
             reply_device = Device()
             reply_device.CopyFrom(device)
             if exclude_endpoints:    del reply_device.device_endpoints [:] # pylint: disable=no-member
@@ -267,92 +262,132 @@ class MockServicerImpl_Context(ContextServiceServicer):
             devices.append(reply_device)
                 
         reply = DeviceList(devices=devices) 
-        LOGGER.info('[SelectDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SelectDevice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
 
     # ----- Link -------------------------------------------------------------------------------------------------------
 
     def ListLinkIds(self, request: Empty, context : grpc.ServicerContext) -> LinkIdList:
-        LOGGER.info('[ListLinkIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = LinkIdList(link_ids=[link.link_id for link in get_entries(self.database, 'link')])
-        LOGGER.info('[ListLinkIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListLinkIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = LinkIdList(link_ids=[link.link_id for link in self.obj_db.get_entries('link')])
+        LOGGER.debug('[ListLinkIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListLinks(self, request: Empty, context : grpc.ServicerContext) -> LinkList:
-        LOGGER.info('[ListLinks] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = LinkList(links=get_entries(self.database, 'link'))
-        LOGGER.info('[ListLinks] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListLinks] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = LinkList(links=self.obj_db.get_entries('link'))
+        LOGGER.debug('[ListLinks] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetLink(self, request: LinkId, context : grpc.ServicerContext) -> Link:
-        LOGGER.info('[GetLink] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = get_entry(context, self.database, 'link', request.link_uuid.uuid)
-        LOGGER.info('[GetLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[GetLink] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self.obj_db.get_entry('link', request.link_uuid.uuid, context)
+        LOGGER.debug('[GetLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetLink(self, request: Context, context : grpc.ServicerContext) -> LinkId:
-        LOGGER.info('[SetLink] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = self._set(request, 'link', request.link_id.link_uuid.uuid, 'link_id', TOPIC_LINK)
-        LOGGER.info('[SetLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SetLink] request={:s}'.format(grpc_message_to_json_string(request)))
+        link_uuid = request.link_id.link_uuid.uuid
+        reply, link = self._set(request, 'link', link_uuid, 'link_id', TOPIC_LINK)
+
+        context_topology_uuids : Set[Tuple[str, str]] = set()
+        context_topology_uuids.add((DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME))
+        for endpoint_id in link.link_endpoint_ids:
+            endpoint_context_uuid = endpoint_id.topology_id.context_id.context_uuid.uuid
+            if len(endpoint_context_uuid) == 0: endpoint_context_uuid = DEFAULT_CONTEXT_NAME
+            endpoint_topology_uuid = endpoint_id.topology_id.topology_uuid.uuid
+            if len(endpoint_topology_uuid) == 0: endpoint_topology_uuid = DEFAULT_TOPOLOGY_NAME
+            context_topology_uuids.add((endpoint_context_uuid, endpoint_topology_uuid))
+
+        for context_uuid,topology_uuid in context_topology_uuids:
+            container_name = 'topology[{:s}]'.format(str(context_uuid))
+            topology = self.obj_db.get_entry(container_name, topology_uuid, context)
+            for _link_id in topology.link_ids:
+                if _link_id.link_uuid.uuid == link_uuid: break
+            else:
+                # link not found, add it
+                topology.link_ids.add().link_uuid.uuid = link_uuid
+
+        LOGGER.debug('[SetLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveLink(self, request: LinkId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveLink] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = self._del(request, 'link', request.link_uuid.uuid, 'link_id', TOPIC_LINK, context)
-        LOGGER.info('[RemoveLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveLink] request={:s}'.format(grpc_message_to_json_string(request)))
+        link_uuid = request.link_uuid.uuid
+        link = self.obj_db.get_entry('link', link_uuid, context)
+        reply = self._del(request, 'link', link_uuid, 'link_id', TOPIC_LINK, context)
+
+        context_topology_uuids : Set[Tuple[str, str]] = set()
+        context_topology_uuids.add((DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME))
+        for endpoint_id in link.link_endpoint_ids:
+            endpoint_context_uuid = endpoint_id.topology_id.context_id.context_uuid.uuid
+            if len(endpoint_context_uuid) == 0: endpoint_context_uuid = DEFAULT_CONTEXT_NAME
+            endpoint_topology_uuid = endpoint_id.topology_id.topology_uuid.uuid
+            if len(endpoint_topology_uuid) == 0: endpoint_topology_uuid = DEFAULT_TOPOLOGY_NAME
+            context_topology_uuids.add((endpoint_context_uuid, endpoint_topology_uuid))
+
+        for context_uuid,topology_uuid in context_topology_uuids:
+            container_name = 'topology[{:s}]'.format(str(context_uuid))
+            topology = self.obj_db.get_entry(container_name, topology_uuid, context)
+            for link_id in topology.link_ids:
+                if link_id.link_uuid.uuid == link_uuid:
+                    topology.link_ids.remove(link_id)
+                    break
+
+        LOGGER.debug('[RemoveLink] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetLinkEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[LinkEvent]:
-        LOGGER.info('[GetLinkEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetLinkEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_LINK}): yield LinkEvent(**json.loads(message.content))
 
 
     # ----- Slice ------------------------------------------------------------------------------------------------------
 
     def ListSliceIds(self, request: ContextId, context : grpc.ServicerContext) -> SliceIdList:
-        LOGGER.info('[ListSliceIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        slices = get_entries(self.database, 'slice[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListSliceIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        slices = self.obj_db.get_entries('slice[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = SliceIdList(slice_ids=[slice.slice_id for slice in slices])
-        LOGGER.info('[ListSliceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListSliceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListSlices(self, request: ContextId, context : grpc.ServicerContext) -> SliceList:
-        LOGGER.info('[ListSlices] request={:s}'.format(grpc_message_to_json_string(request)))
-        slices = get_entries(self.database, 'slice[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListSlices] request={:s}'.format(grpc_message_to_json_string(request)))
+        slices = self.obj_db.get_entries('slice[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = SliceList(slices=[slice for slice in slices])
-        LOGGER.info('[ListSlices] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListSlices] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetSlice(self, request: SliceId, context : grpc.ServicerContext) -> Slice:
-        LOGGER.info('[GetSlice] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetSlice] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'slice[{:s}]'.format(str(request.context_id.context_uuid.uuid))
-        reply = get_entry(context, self.database, container_name, request.slice_uuid.uuid)
-        LOGGER.info('[GetSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply = self.obj_db.get_entry(container_name, request.slice_uuid.uuid, context)
+        LOGGER.debug('[GetSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetSlice(self, request: Slice, context : grpc.ServicerContext) -> SliceId:
-        LOGGER.info('[SetSlice] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SetSlice] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'slice[{:s}]'.format(str(request.slice_id.context_id.context_uuid.uuid))
         slice_uuid = request.slice_id.slice_uuid.uuid
-        reply = self._set(request, container_name, slice_uuid, 'slice_id', TOPIC_SLICE)
-        LOGGER.info('[SetSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply,_ = self._set(request, container_name, slice_uuid, 'slice_id', TOPIC_SLICE)
+        LOGGER.debug('[SetSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveSlice(self, request: SliceId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveSlice] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[RemoveSlice] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'slice[{:s}]'.format(str(request.context_id.context_uuid.uuid))
         slice_uuid = request.slice_uuid.uuid
         reply = self._del(request, container_name, slice_uuid, 'slice_id', TOPIC_SLICE, context)
-        LOGGER.info('[RemoveSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetSliceEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[SliceEvent]:
-        LOGGER.info('[GetSliceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetSliceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_SLICE}): yield SliceEvent(**json.loads(message.content))
 
     def SelectSlice(self, request : SliceFilter, context : grpc.ServicerContext) -> SliceList:
-        LOGGER.info('[SelectSlice] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SelectSlice] request={:s}'.format(grpc_message_to_json_string(request)))
         container_entry_uuids : Dict[str, Set[str]] = {}
         for slice_id in request.slice_ids.slice_ids:
             container_name = 'slice[{:s}]'.format(str(slice_id.context_id.context_uuid.uuid))
@@ -368,7 +403,7 @@ class MockServicerImpl_Context(ContextServiceServicer):
         slices = list()
         for container_name in sorted(container_entry_uuids.keys()):
             entry_uuids = container_entry_uuids[container_name]
-            for eslice in select_entries(self.database, container_name, entry_uuids):
+            for eslice in self.obj_db.select_entries(container_name, entry_uuids):
                 reply_slice = Slice()
                 reply_slice.CopyFrom(eslice)
                 if exclude_endpoint_ids: del reply_slice.service_endpoint_ids[:] # pylint: disable=no-member
@@ -379,55 +414,55 @@ class MockServicerImpl_Context(ContextServiceServicer):
                 slices.append(reply_slice)
                 
         reply = SliceList(slices=slices)
-        LOGGER.info('[SelectSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SelectSlice] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
 
     # ----- Service ----------------------------------------------------------------------------------------------------
 
     def ListServiceIds(self, request: ContextId, context : grpc.ServicerContext) -> ServiceIdList:
-        LOGGER.info('[ListServiceIds] request={:s}'.format(grpc_message_to_json_string(request)))
-        services = get_entries(self.database, 'service[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListServiceIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        services = self.obj_db.get_entries('service[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = ServiceIdList(service_ids=[service.service_id for service in services])
-        LOGGER.info('[ListServiceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListServiceIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListServices(self, request: ContextId, context : grpc.ServicerContext) -> ServiceList:
-        LOGGER.info('[ListServices] request={:s}'.format(grpc_message_to_json_string(request)))
-        services = get_entries(self.database, 'service[{:s}]'.format(str(request.context_uuid.uuid)))
+        LOGGER.debug('[ListServices] request={:s}'.format(grpc_message_to_json_string(request)))
+        services = self.obj_db.get_entries('service[{:s}]'.format(str(request.context_uuid.uuid)))
         reply = ServiceList(services=[service for service in services])
-        LOGGER.info('[ListServices] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[ListServices] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetService(self, request: ServiceId, context : grpc.ServicerContext) -> Service:
-        LOGGER.info('[GetService] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetService] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service[{:s}]'.format(str(request.context_id.context_uuid.uuid))
-        reply = get_entry(context, self.database, container_name, request.service_uuid.uuid)
-        LOGGER.info('[GetService] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply = self.obj_db.get_entry(container_name, request.service_uuid.uuid, context)
+        LOGGER.debug('[GetService] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetService(self, request: Service, context : grpc.ServicerContext) -> ServiceId:
-        LOGGER.info('[SetService] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SetService] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service[{:s}]'.format(str(request.service_id.context_id.context_uuid.uuid))
         service_uuid = request.service_id.service_uuid.uuid
-        reply = self._set(request, container_name, service_uuid, 'service_id', TOPIC_SERVICE)
-        LOGGER.info('[SetService] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply,_ = self._set(request, container_name, service_uuid, 'service_id', TOPIC_SERVICE)
+        LOGGER.debug('[SetService] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveService(self, request: ServiceId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveService] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[RemoveService] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service[{:s}]'.format(str(request.context_id.context_uuid.uuid))
         service_uuid = request.service_uuid.uuid
         reply = self._del(request, container_name, service_uuid, 'service_id', TOPIC_SERVICE, context)
-        LOGGER.info('[RemoveService] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveService] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetServiceEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[ServiceEvent]:
-        LOGGER.info('[GetServiceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetServiceEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_SERVICE}): yield ServiceEvent(**json.loads(message.content))
 
     def SelectService(self, request : ServiceFilter, context : grpc.ServicerContext) -> ServiceList:
-        LOGGER.info('[SelectService] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SelectService] request={:s}'.format(grpc_message_to_json_string(request)))
         container_entry_uuids : Dict[str, Set[str]] = {}
         for service_id in request.service_ids.service_ids:
             container_name = 'service[{:s}]'.format(str(service_id.context_id.context_uuid.uuid))
@@ -441,7 +476,7 @@ class MockServicerImpl_Context(ContextServiceServicer):
         services = list()
         for container_name in sorted(container_entry_uuids.keys()):
             entry_uuids = container_entry_uuids[container_name]
-            for service in select_entries(self.database, container_name, entry_uuids):
+            for service in self.obj_db.select_entries(container_name, entry_uuids):
                 reply_service = Service()
                 reply_service.CopyFrom(service)
                 if exclude_endpoint_ids: del reply_service.service_endpoint_ids[:] # pylint: disable=no-member
@@ -450,54 +485,54 @@ class MockServicerImpl_Context(ContextServiceServicer):
                 services.append(reply_service)
                 
         reply = ServiceList(services=services) 
-        LOGGER.info('[SelectService] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[SelectService] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     # ----- Connection -------------------------------------------------------------------------------------------------
 
     def ListConnectionIds(self, request: ServiceId, context : grpc.ServicerContext) -> ConnectionIdList:
-        LOGGER.info('[ListConnectionIds] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[ListConnectionIds] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service_connections[{:s}/{:s}]'.format(
             str(request.context_id.context_uuid.uuid), str(request.service_uuid.uuid))
-        reply = ConnectionIdList(connection_ids=[c.connection_id for c in get_entries(self.database, container_name)])
-        LOGGER.info('[ListConnectionIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply = ConnectionIdList(connection_ids=[c.connection_id for c in self.obj_db.get_entries(container_name)])
+        LOGGER.debug('[ListConnectionIds] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def ListConnections(self, request: ServiceId, context : grpc.ServicerContext) -> ConnectionList:
-        LOGGER.info('[ListConnections] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[ListConnections] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service_connections[{:s}/{:s}]'.format(
             str(request.context_id.context_uuid.uuid), str(request.service_uuid.uuid))
-        reply = ConnectionList(connections=get_entries(self.database, container_name))
-        LOGGER.info('[ListConnections] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        reply = ConnectionList(connections=self.obj_db.get_entries(container_name))
+        LOGGER.debug('[ListConnections] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetConnection(self, request: ConnectionId, context : grpc.ServicerContext) -> Connection:
-        LOGGER.info('[GetConnection] request={:s}'.format(grpc_message_to_json_string(request)))
-        reply = get_entry(context, self.database, 'connection', request.connection_uuid.uuid)
-        LOGGER.info('[GetConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[GetConnection] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self.obj_db.get_entry('connection', request.connection_uuid.uuid, context)
+        LOGGER.debug('[GetConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def SetConnection(self, request: Connection, context : grpc.ServicerContext) -> ConnectionId:
-        LOGGER.info('[SetConnection] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[SetConnection] request={:s}'.format(grpc_message_to_json_string(request)))
         container_name = 'service_connection[{:s}/{:s}]'.format(
             str(request.service_id.context_id.context_uuid.uuid), str(request.service_id.service_uuid.uuid))
         connection_uuid = request.connection_id.connection_uuid.uuid
-        set_entry(self.database, container_name, connection_uuid, request)
-        reply = self._set(request, 'connection', connection_uuid, 'connection_id', TOPIC_CONNECTION)
-        LOGGER.info('[SetConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        self.obj_db.set_entry(container_name, connection_uuid, request)
+        reply,_ = self._set(request, 'connection', connection_uuid, 'connection_id', TOPIC_CONNECTION)
+        LOGGER.debug('[SetConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def RemoveConnection(self, request: ConnectionId, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[RemoveConnection] request={:s}'.format(grpc_message_to_json_string(request)))
-        connection = get_entry(context, self.database, 'connection', request.connection_uuid.uuid)
+        LOGGER.debug('[RemoveConnection] request={:s}'.format(grpc_message_to_json_string(request)))
+        connection = self.obj_db.get_entry('connection', request.connection_uuid.uuid, context)
         container_name = 'service_connection[{:s}/{:s}]'.format(
             str(connection.service_id.context_id.context_uuid.uuid), str(connection.service_id.service_uuid.uuid))
         connection_uuid = request.connection_uuid.uuid
-        del_entry(context, self.database, container_name, connection_uuid)
+        self.obj_db.del_entry(container_name, connection_uuid, context)
         reply = self._del(request, 'connection', connection_uuid, 'connection_id', TOPIC_CONNECTION, context)
-        LOGGER.info('[RemoveConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        LOGGER.debug('[RemoveConnection] reply={:s}'.format(grpc_message_to_json_string(reply)))
         return reply
 
     def GetConnectionEvents(self, request: Empty, context : grpc.ServicerContext) -> Iterator[ConnectionEvent]:
-        LOGGER.info('[GetConnectionEvents] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[GetConnectionEvents] request={:s}'.format(grpc_message_to_json_string(request)))
         for message in self.msg_broker.consume({TOPIC_CONNECTION}): yield ConnectionEvent(**json.loads(message.content))
diff --git a/src/common/tests/MockServicerImpl_Monitoring.py b/src/common/tests/MockServicerImpl_Monitoring.py
index 7bebf8732dece43fd6c0b5982ea93c70d3ce0bea..4aadb8e5e20575321df2003c69a5ab9fe2390af8 100644
--- a/src/common/tests/MockServicerImpl_Monitoring.py
+++ b/src/common/tests/MockServicerImpl_Monitoring.py
@@ -12,23 +12,107 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
-import grpc, logging
+import enum, grpc, logging
 from queue import Queue
+from typing import Any, Optional
 from common.proto.context_pb2 import Empty
-from common.proto.monitoring_pb2 import Kpi
+from common.proto.monitoring_pb2 import Kpi, KpiDescriptor, KpiDescriptorList, KpiId, KpiQuery, RawKpiTable
 from common.proto.monitoring_pb2_grpc import MonitoringServiceServicer
 from common.tools.grpc.Tools import grpc_message_to_json_string
+from .InMemoryObjectDatabase import InMemoryObjectDatabase
+from .InMemoryTimeSeriesDatabase import InMemoryTimeSeriesDatabase
 
 LOGGER = logging.getLogger(__name__)
 
+class IMDB_ContainersEnum(enum.Enum):
+    KPI_DESCRIPTORS = 'kpi_descriptor'
+
 class MockServicerImpl_Monitoring(MonitoringServiceServicer):
-    def __init__(self, queue_samples : Queue):
-        LOGGER.info('[__init__] Creating Servicer...')
+    def __init__(
+        self, queue_samples : Optional[Queue] = None
+    ) -> None:
+        LOGGER.debug('[__init__] Creating Servicer...')
+        if queue_samples is None: queue_samples = Queue()
         self.queue_samples = queue_samples
-        LOGGER.info('[__init__] Servicer Created')
+        self.obj_db = InMemoryObjectDatabase()
+        self.ts_db  = InMemoryTimeSeriesDatabase()
+        LOGGER.debug('[__init__] Servicer Created')
+
+    # ----- Common -----------------------------------------------------------------------------------------------------
+
+    def _set(self, container_name, entry_uuid, entry_id_field_name, entry) -> Any:
+        entry = self.obj_db.set_entry(container_name, entry_uuid, entry)
+        return getattr(entry, entry_id_field_name)
+
+    def _del(self, container_name, entry_uuid, grpc_context) -> Empty:
+        self.obj_db.del_entry(container_name, entry_uuid, grpc_context)
+        return Empty()
+
+    # ----- KPI Descriptor ---------------------------------------------------------------------------------------------
+
+    def GetKpiDescriptorList(self, request : Empty, context : grpc.ServicerContext) -> KpiDescriptorList:
+        LOGGER.debug('[GetKpiDescriptorList] request={:s}'.format(grpc_message_to_json_string(request)))
+        kpi_descriptor_list = self.obj_db.get_entries(IMDB_ContainersEnum.KPI_DESCRIPTORS.value)
+        reply = KpiDescriptorList(kpi_descriptor_list=kpi_descriptor_list)
+        LOGGER.debug('[GetKpiDescriptorList] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
+
+    def GetKpiDescriptor(self, request : KpiId, context : grpc.ServicerContext) -> KpiDescriptor:
+        LOGGER.debug('[GetKpiDescriptor] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self.obj_db.get_entry(IMDB_ContainersEnum.KPI_DESCRIPTORS.value, request.kpi_id.uuid, context)
+        LOGGER.debug('[GetKpiDescriptor] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
+
+    def SetKpi(self, request : KpiDescriptor, context : grpc.ServicerContext) -> KpiId:
+        LOGGER.debug('[SetKpi] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self._set(IMDB_ContainersEnum.KPI_DESCRIPTORS.value, request.kpi_id.kpi_id.uuid, 'kpi_id', request)
+        LOGGER.debug('[SetKpi] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
+
+    def DeleteKpi(self, request : KpiId, context : grpc.ServicerContext) -> Empty:
+        LOGGER.debug('[DeleteKpi] request={:s}'.format(grpc_message_to_json_string(request)))
+        reply = self._del(IMDB_ContainersEnum.KPI_DESCRIPTORS.value, request.kpi_id.kpi_id.uuid, context)
+        LOGGER.debug('[DeleteKpi] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
+
+    # ----- KPI Sample -------------------------------------------------------------------------------------------------
 
     def IncludeKpi(self, request : Kpi, context : grpc.ServicerContext) -> Empty:
-        LOGGER.info('[IncludeKpi] request={:s}'.format(grpc_message_to_json_string(request)))
+        LOGGER.debug('[IncludeKpi] request={:s}'.format(grpc_message_to_json_string(request)))
         self.queue_samples.put(request)
-        return Empty()
+        reply = Empty()
+        LOGGER.debug('[IncludeKpi] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
+
+    def QueryKpiData(self, request : KpiQuery, context : grpc.ServicerContext) -> RawKpiTable:
+        LOGGER.debug('[QueryKpiData] request={:s}'.format(grpc_message_to_json_string(request)))
+        # TODO: add filters for request.monitoring_window_s
+        # TODO: add filters for request.last_n_samples
+        kpi_uuids = [kpi_id.kpi_id.uuid for kpi_id in request.kpi_ids]
+
+        start_timestamp = request.start_timestamp.timestamp
+        if start_timestamp <= 0: start_timestamp = None
+
+        end_timestamp = request.end_timestamp.timestamp
+        if end_timestamp <= 0: end_timestamp = None
+
+        df_samples = self.ts_db.filter(kpi_uuids, start_timestamp=start_timestamp, end_timestamp=end_timestamp)
+        #LOGGER.debug('[QueryKpiData] df_samples={:s}'.format(df_samples.to_string()))
+        reply = RawKpiTable()
+        kpi_uuid__to__raw_kpi_list = dict()
+
+        for df_sample in df_samples.itertuples():
+            kpi_uuid  = df_sample.kpi_uuid
+            if kpi_uuid in kpi_uuid__to__raw_kpi_list:
+                raw_kpi_list = kpi_uuid__to__raw_kpi_list[kpi_uuid]
+            else:
+                raw_kpi_list = reply.raw_kpi_lists.add()    # pylint: disable=no-member
+                raw_kpi_list.kpi_id.kpi_id.uuid = kpi_uuid
+                kpi_uuid__to__raw_kpi_list[kpi_uuid] = raw_kpi_list
+
+            raw_kpi = raw_kpi_list.raw_kpis.add()
+            raw_kpi.timestamp.timestamp = df_sample.timestamp.timestamp()
+            raw_kpi.kpi_value.floatVal  = df_sample.value
+
+        LOGGER.debug('[QueryKpiData] reply={:s}'.format(grpc_message_to_json_string(reply)))
+        return reply
diff --git a/src/common/tools/object_factory/Constraint.py b/src/common/tools/object_factory/Constraint.py
index ef00e3872343196f0a9f8de97d3b1ab6fc12d847..9fccd9d5f97d64cac2dea441bbbb374d638df114 100644
--- a/src/common/tools/object_factory/Constraint.py
+++ b/src/common/tools/object_factory/Constraint.py
@@ -19,6 +19,9 @@ def json_constraint_custom(constraint_type : str, constraint_value : Union[str,
     if not isinstance(constraint_value, str): constraint_value = json.dumps(constraint_value, sort_keys=True)
     return {'custom': {'constraint_type': constraint_type, 'constraint_value': constraint_value}}
 
+def json_constraint_schedule(start_timestamp : float, duration_days : float) -> Dict:
+    return {'schedule': {'start_timestamp': start_timestamp, 'duration_days': duration_days}}
+
 def json_constraint_endpoint_location_region(endpoint_id : Dict, region : str) -> Dict:
     return {'endpoint_location': {'endpoint_id': endpoint_id, 'location': {'region': region}}}
 
@@ -29,16 +32,27 @@ def json_constraint_endpoint_location_gps(endpoint_id : Dict, latitude : float,
 def json_constraint_endpoint_priority(endpoint_id : Dict, priority : int) -> Dict:
     return {'endpoint_priority': {'endpoint_id': endpoint_id, 'priority': priority}}
 
+def json_constraint_sla_capacity(capacity_gbps : float) -> Dict:
+    return {'sla_capacity': {'capacity_gbps': capacity_gbps}}
+
+def json_constraint_sla_latency(e2e_latency_ms : float) -> Dict:
+    return {'sla_latency': {'e2e_latency_ms': e2e_latency_ms}}
+
 def json_constraint_sla_availability(num_disjoint_paths : int, all_active : bool, availability : float) -> Dict:
     return {'sla_availability': {
         'num_disjoint_paths': num_disjoint_paths, 'all_active': all_active, 'availability': availability
     }}
 
-def json_constraint_sla_capacity(capacity_gbps : float) -> Dict:
-    return {'sla_capacity': {'capacity_gbps': capacity_gbps}}
-
 def json_constraint_sla_isolation(isolation_levels : List[int]) -> Dict:
     return {'sla_isolation': {'isolation_level': isolation_levels}}
 
-def json_constraint_sla_latency(e2e_latency_ms : float) -> Dict:
-    return {'sla_latency': {'e2e_latency_ms': e2e_latency_ms}}
+def json_constraint_exclusions(
+    is_permanent : bool = False, device_ids : List[Dict] = [], endpoint_ids : List[Dict] = [],
+    link_ids : List[Dict] = []
+) -> Dict:
+    return {'exclusions': {
+        'is_permanent' : is_permanent,
+        'device_ids'   : device_ids,
+        'endpoint_ids' : endpoint_ids,
+        'link_ids'     : link_ids,
+    }}
diff --git a/src/common/tools/object_factory/Link.py b/src/common/tools/object_factory/Link.py
index 5f8080d300d9d6d646b8d769ec5819b0bd26f789..c0a4c48d1beea64e6591e47441509fa2cc42c02b 100644
--- a/src/common/tools/object_factory/Link.py
+++ b/src/common/tools/object_factory/Link.py
@@ -23,13 +23,28 @@ def get_link_uuid(a_endpoint_id : Dict, z_endpoint_id : Dict) -> str:
 def json_link_id(link_uuid : str) -> Dict:
     return {'link_uuid': {'uuid': link_uuid}}
 
-def json_link(link_uuid : str, endpoint_ids : List[Dict], name : Optional[str] = None) -> Dict:
+def json_link(
+    link_uuid : str, endpoint_ids : List[Dict], name : Optional[str] = None,
+    total_capacity_gbps : Optional[float] = None, used_capacity_gbps : Optional[float] = None
+) -> Dict:
     result = {'link_id': json_link_id(link_uuid), 'link_endpoint_ids': copy.deepcopy(endpoint_ids)}
     if name is not None: result['name'] = name
+    if total_capacity_gbps is not None:
+        attributes : Dict = result.setdefault('attributes', dict())
+        attributes.setdefault('total_capacity_gbps', total_capacity_gbps)
+    if used_capacity_gbps is not None:
+        attributes : Dict = result.setdefault('attributes', dict())
+        attributes.setdefault('used_capacity_gbps', used_capacity_gbps)
     return result
 
-def compose_link(endpoint_a, endpoint_z) -> Tuple[Dict, Dict]:
+def compose_link(
+    endpoint_a : Dict, endpoint_z : Dict, name : Optional[str] = None,
+    total_capacity_gbps : Optional[float] = None, used_capacity_gbps : Optional[float] = None
+) -> Tuple[Dict, Dict]:
     link_uuid = get_link_uuid(endpoint_a['endpoint_id'], endpoint_z['endpoint_id'])
     link_id   = json_link_id(link_uuid)
-    link      = json_link(link_uuid, [endpoint_a['endpoint_id'], endpoint_z['endpoint_id']])
+    link      = json_link(
+        link_uuid, [endpoint_a['endpoint_id'], endpoint_z['endpoint_id']], name=name,
+        total_capacity_gbps=total_capacity_gbps, used_capacity_gbps=used_capacity_gbps
+    )
     return link_id, link
diff --git a/src/common/tools/timestamp/Converters.py b/src/common/tools/timestamp/Converters.py
index 0ef8e0863b71b610602dfc0ee4fc7c72d25a1139..7918017390e60bd7830d3513216fc0b8f6cf83ef 100644
--- a/src/common/tools/timestamp/Converters.py
+++ b/src/common/tools/timestamp/Converters.py
@@ -13,14 +13,23 @@
 # limitations under the License.
 
 
-import dateutil.parser
+import dateutil.parser, math
 from datetime import datetime, timezone
 
+def timestamp_datetime_to_float(dt_timestamp : datetime) -> int:
+    return math.floor(dt_timestamp.timestamp())
+
+def timestamp_datetime_to_int(dt_timestamp : datetime) -> int:
+    return math.floor(timestamp_datetime_to_float(dt_timestamp))
+
 def timestamp_string_to_float(str_timestamp : str) -> float:
-    return datetime.timestamp(dateutil.parser.isoparse(str_timestamp))
+    return timestamp_datetime_to_float(dateutil.parser.isoparse(str_timestamp))
 
 def timestamp_float_to_string(flt_timestamp : float) -> str:
     return datetime.utcfromtimestamp(flt_timestamp).isoformat() + 'Z'
 
+def timestamp_utcnow_to_datetime() -> datetime:
+    return datetime.now(tz=timezone.utc)
+
 def timestamp_utcnow_to_float() -> float:
-    return datetime.timestamp(datetime.now(tz=timezone.utc))
+    return timestamp_datetime_to_float(timestamp_utcnow_to_datetime())
diff --git a/src/common/type_checkers/Assertions.py b/src/common/type_checkers/Assertions.py
index 42ea864f3c0c1150c3806f97e67ff3969542ab70..286ae179d325b6e70d6ebf509de92e354ba42bc8 100644
--- a/src/common/type_checkers/Assertions.py
+++ b/src/common/type_checkers/Assertions.py
@@ -53,6 +53,8 @@ def validate_kpi_sample_types_enum(message):
         'KPISAMPLETYPE_PACKETS_RECEIVED',
         'KPISAMPLETYPE_BYTES_TRANSMITTED',
         'KPISAMPLETYPE_BYTES_RECEIVED',
+        'KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS',
+        'KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS',
     ]
 
 def validate_service_type_enum(message):
diff --git a/src/context/.gitlab-ci.yml b/src/context/.gitlab-ci.yml
index 63fc2d94307f556140e15e984789b9495f2d8270..5de4bc1fcbb5bea98a7675253efe060df03a1237 100644
--- a/src/context/.gitlab-ci.yml
+++ b/src/context/.gitlab-ci.yml
@@ -53,6 +53,7 @@ unit_test context:
     - if docker volume ls | grep crdb; then docker volume rm -f crdb; else echo "CockroachDB volume is not in the system"; fi
     - if docker container ls | grep nats; then docker rm -f nats; else echo "NATS container is not in the system"; fi
     - if docker container ls | grep $IMAGE_NAME; then docker rm -f $IMAGE_NAME; else echo "$IMAGE_NAME container is not in the system"; fi
+    - docker container prune -f
   script:
     - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
     - docker pull "cockroachdb/cockroach:latest-v22.2"
diff --git a/src/context/service/database/Link.py b/src/context/service/database/Link.py
index 67ac9f518f610caedc631444187cac10aded56c7..4ca3cee68e45cc9b5a8f4e0d9f1b07a3ec39f268 100644
--- a/src/context/service/database/Link.py
+++ b/src/context/service/database/Link.py
@@ -99,11 +99,25 @@ def link_set(db_engine : Engine, messagebroker : MessageBroker, request : Link)
             })
             topology_uuids.add(endpoint_topology_uuid)
 
+    total_capacity_gbps, used_capacity_gbps = None, None
+    if request.HasField('attributes'):
+        attributes = request.attributes
+        # In proto3, HasField() does not work for scalar fields, using ListFields() instead.
+        attribute_names = set([field.name for field,_ in attributes.ListFields()])
+        if 'total_capacity_gbps' in attribute_names:
+            total_capacity_gbps = attributes.total_capacity_gbps
+        if 'used_capacity_gbps' in attribute_names:
+            used_capacity_gbps = attributes.used_capacity_gbps
+        elif total_capacity_gbps is not None:
+            used_capacity_gbps = total_capacity_gbps
+
     link_data = [{
-        'link_uuid' : link_uuid,
-        'link_name' : link_name,
-        'created_at': now,
-        'updated_at': now,
+        'link_uuid'           : link_uuid,
+        'link_name'           : link_name,
+        'total_capacity_gbps' : total_capacity_gbps,
+        'used_capacity_gbps'  : used_capacity_gbps,
+        'created_at'          : now,
+        'updated_at'          : now,
     }]
 
     def callback(session : Session) -> Tuple[bool, List[Dict]]:
@@ -111,8 +125,10 @@ def link_set(db_engine : Engine, messagebroker : MessageBroker, request : Link)
         stmt = stmt.on_conflict_do_update(
             index_elements=[LinkModel.link_uuid],
             set_=dict(
-                link_name  = stmt.excluded.link_name,
-                updated_at = stmt.excluded.updated_at,
+                link_name           = stmt.excluded.link_name,
+                total_capacity_gbps = stmt.excluded.total_capacity_gbps,
+                used_capacity_gbps  = stmt.excluded.used_capacity_gbps,
+                updated_at          = stmt.excluded.updated_at,
             )
         )
         stmt = stmt.returning(LinkModel.created_at, LinkModel.updated_at)
diff --git a/src/context/service/database/models/LinkModel.py b/src/context/service/database/models/LinkModel.py
index 9c16da3c9146f28352e8b4f7a6f9ab85f870c8b7..d91666652e6b7e506b9718903d0fb095b4ea69c4 100644
--- a/src/context/service/database/models/LinkModel.py
+++ b/src/context/service/database/models/LinkModel.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 import operator
-from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, String
+from sqlalchemy import CheckConstraint, Column, DateTime, Float, ForeignKey, Integer, String
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.orm import relationship
 from typing import Dict
@@ -22,19 +22,26 @@ from ._Base import _Base
 class LinkModel(_Base):
     __tablename__ = 'link'
 
-    link_uuid  = Column(UUID(as_uuid=False), primary_key=True)
-    link_name  = Column(String, nullable=False)
-    created_at = Column(DateTime, nullable=False)
-    updated_at = Column(DateTime, nullable=False)
+    link_uuid           = Column(UUID(as_uuid=False), primary_key=True)
+    link_name           = Column(String, nullable=False)
+    total_capacity_gbps = Column(Float, nullable=True)
+    used_capacity_gbps  = Column(Float, nullable=True)
+    created_at          = Column(DateTime, nullable=False)
+    updated_at          = Column(DateTime, nullable=False)
 
     #topology_links = relationship('TopologyLinkModel', back_populates='link')
     link_endpoints = relationship('LinkEndPointModel') # lazy='joined', back_populates='link'
 
+    __table_args__ = (
+        CheckConstraint(total_capacity_gbps >= 0, name='check_value_total_capacity_gbps'),
+        CheckConstraint(used_capacity_gbps  >= 0, name='check_value_used_capacity_gbps' ),
+    )
+
     def dump_id(self) -> Dict:
         return {'link_uuid': {'uuid': self.link_uuid}}
 
     def dump(self) -> Dict:
-        return {
+        result = {
             'link_id'          : self.dump_id(),
             'name'             : self.link_name,
             'link_endpoint_ids': [
@@ -42,6 +49,13 @@ class LinkModel(_Base):
                 for link_endpoint in sorted(self.link_endpoints, key=operator.attrgetter('position'))
             ],
         }
+        if self.total_capacity_gbps is not None:
+            attributes : Dict = result.setdefault('attributes', dict())
+            attributes.setdefault('total_capacity_gbps', self.total_capacity_gbps)
+        if self.used_capacity_gbps is not None:
+            attributes : Dict = result.setdefault('attributes', dict())
+            attributes.setdefault('used_capacity_gbps', self.used_capacity_gbps)
+        return result
 
 class LinkEndPointModel(_Base):
     __tablename__ = 'link_endpoint'
diff --git a/src/context/service/database/models/enums/KpiSampleType.py b/src/context/service/database/models/enums/KpiSampleType.py
index 5cef9ac199a0cc3389092e4ea375940e27554066..a229b5698ecc393afced41f885bf4c88ede4543f 100644
--- a/src/context/service/database/models/enums/KpiSampleType.py
+++ b/src/context/service/database/models/enums/KpiSampleType.py
@@ -22,11 +22,13 @@ from ._GrpcToEnum import grpc_to_enum
 #            BYTES_RECEIVED. If item name does not match, automatic mapping of
 #            proto enums to database enums will fail.
 class ORM_KpiSampleTypeEnum(enum.Enum):
-    UNKNOWN             = KpiSampleType.KPISAMPLETYPE_UNKNOWN
-    PACKETS_TRANSMITTED = KpiSampleType.KPISAMPLETYPE_PACKETS_TRANSMITTED
-    PACKETS_RECEIVED    = KpiSampleType.KPISAMPLETYPE_PACKETS_RECEIVED
-    BYTES_TRANSMITTED   = KpiSampleType.KPISAMPLETYPE_BYTES_TRANSMITTED
-    BYTES_RECEIVED      = KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED
+    UNKNOWN                  = KpiSampleType.KPISAMPLETYPE_UNKNOWN
+    PACKETS_TRANSMITTED      = KpiSampleType.KPISAMPLETYPE_PACKETS_TRANSMITTED
+    PACKETS_RECEIVED         = KpiSampleType.KPISAMPLETYPE_PACKETS_RECEIVED
+    BYTES_TRANSMITTED        = KpiSampleType.KPISAMPLETYPE_BYTES_TRANSMITTED
+    BYTES_RECEIVED           = KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED
+    LINK_TOTAL_CAPACITY_GBPS = KpiSampleType.KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS
+    LINK_USED_CAPACITY_GBPS  = KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
 
 grpc_to_enum__kpi_sample_type = functools.partial(
     grpc_to_enum, KpiSampleType, ORM_KpiSampleTypeEnum)
diff --git a/src/context/tests/Objects.py b/src/context/tests/Objects.py
index 6b52ef4c0f3583de628706ba79efffb9d5709820..785a50e1934269150381a1d6d3b08001574a0cee 100644
--- a/src/context/tests/Objects.py
+++ b/src/context/tests/Objects.py
@@ -71,18 +71,32 @@ DEVICE_R3_NAME, DEVICE_R3_ID, DEVICE_R3 = compose_device('R3', ['1.1', '1.2', '2
 
 
 # ----- Link -----------------------------------------------------------------------------------------------------------
-def compose_link(name : str, endpoint_ids : List[Tuple[str, str]]) -> Tuple[str, Dict, Dict]:
+def compose_link(
+    name : str, endpoint_ids : List[Tuple[str, str]],
+    total_capacity_gbps : Optional[float] = None, used_capacity_gbps : Optional[float] = None
+) -> Tuple[str, Dict, Dict]:
     link_id = json_link_id(name)
     endpoint_ids = [
         json_endpoint_id(device_id, endpoint_name, topology_id=TOPOLOGY_ID)
         for device_id, endpoint_name in endpoint_ids
     ]
-    link = json_link(name, endpoint_ids)
+    link = json_link(
+        name, endpoint_ids, total_capacity_gbps=total_capacity_gbps, used_capacity_gbps=used_capacity_gbps
+    )
     return name, link_id, link
 
-LINK_R1_R2_NAME, LINK_R1_R2_ID, LINK_R1_R2 = compose_link('R1==R2', [(DEVICE_R1_ID, '1.2'), (DEVICE_R2_ID, '1.1')])
-LINK_R2_R3_NAME, LINK_R2_R3_ID, LINK_R2_R3 = compose_link('R2==R3', [(DEVICE_R2_ID, '1.3'), (DEVICE_R3_ID, '1.2')])
-LINK_R1_R3_NAME, LINK_R1_R3_ID, LINK_R1_R3 = compose_link('R1==R3', [(DEVICE_R1_ID, '1.3'), (DEVICE_R3_ID, '1.1')])
+LINK_R1_R2_NAME, LINK_R1_R2_ID, LINK_R1_R2 = compose_link(
+    'R1==R2', [(DEVICE_R1_ID, '1.2'), (DEVICE_R2_ID, '1.1')],
+    total_capacity_gbps=100, # used_capacity_gbps=None => used_capacity_gbps=total_capacity_gbps
+)
+LINK_R2_R3_NAME, LINK_R2_R3_ID, LINK_R2_R3 = compose_link(
+    'R2==R3', [(DEVICE_R2_ID, '1.3'), (DEVICE_R3_ID, '1.2')],
+    total_capacity_gbps=100, # used_capacity_gbps=None => used_capacity_gbps=total_capacity_gbps
+)
+LINK_R1_R3_NAME, LINK_R1_R3_ID, LINK_R1_R3 = compose_link(
+    'R1==R3', [(DEVICE_R1_ID, '1.3'), (DEVICE_R3_ID, '1.1')],
+    total_capacity_gbps=100, # used_capacity_gbps=None => used_capacity_gbps=total_capacity_gbps
+)
 
 
 # ----- Service --------------------------------------------------------------------------------------------------------
diff --git a/src/context/tests/test_link.py b/src/context/tests/test_link.py
index 894ef8ef1472e4b451314970883cb9467c63b02b..8b07f0230cc12add4ab0f2db78f3663cb021ca3a 100644
--- a/src/context/tests/test_link.py
+++ b/src/context/tests/test_link.py
@@ -95,6 +95,13 @@ def test_link(context_client : ContextClient) -> None:
     assert response.link_id.link_uuid.uuid == link_uuid
     assert response.name == LINK_R1_R2_NAME
     assert len(response.link_endpoint_ids) == 2
+    assert response.HasField('attributes')
+    # In proto3, HasField() does not work for scalar fields, using ListFields() instead.
+    attribute_names = set([field.name for field,_ in response.attributes.ListFields()])
+    assert 'total_capacity_gbps' in attribute_names
+    assert abs(response.attributes.total_capacity_gbps - 100) < 1.e-12
+    assert 'used_capacity_gbps' in attribute_names
+    assert abs(response.attributes.used_capacity_gbps - response.attributes.total_capacity_gbps) < 1.e-12
 
     # ----- List when the object exists --------------------------------------------------------------------------------
     response = context_client.ListLinkIds(Empty())
@@ -111,6 +118,8 @@ def test_link(context_client : ContextClient) -> None:
     new_link_name = 'new'
     LINK_UPDATED = copy.deepcopy(LINK_R1_R2)
     LINK_UPDATED['name'] = new_link_name
+    LINK_UPDATED['attributes']['total_capacity_gbps'] = 200
+    LINK_UPDATED['attributes']['used_capacity_gbps'] = 50
     response = context_client.SetLink(Link(**LINK_UPDATED))
     assert response.link_uuid.uuid == link_uuid
 
@@ -125,6 +134,13 @@ def test_link(context_client : ContextClient) -> None:
     assert response.link_id.link_uuid.uuid == link_uuid
     assert response.name == new_link_name
     assert len(response.link_endpoint_ids) == 2
+    assert response.HasField('attributes')
+    # In proto3, HasField() does not work for scalar fields, using ListFields() instead.
+    attribute_names = set([field.name for field,_ in response.attributes.ListFields()])
+    assert 'total_capacity_gbps' in attribute_names
+    assert abs(response.attributes.total_capacity_gbps - 200) < 1.e-12
+    assert 'used_capacity_gbps' in attribute_names
+    assert abs(response.attributes.used_capacity_gbps - 50) < 1.e-12
 
     # ----- List when the object is modified ---------------------------------------------------------------------------
     response = context_client.ListLinkIds(Empty())
@@ -136,6 +152,14 @@ def test_link(context_client : ContextClient) -> None:
     assert response.links[0].link_id.link_uuid.uuid == link_uuid
     assert response.links[0].name == new_link_name
     assert len(response.links[0].link_endpoint_ids) == 2
+    assert len(response.links[0].link_endpoint_ids) == 2
+    assert response.links[0].HasField('attributes')
+    # In proto3, HasField() does not work for scalar fields, using ListFields() instead.
+    attribute_names = set([field.name for field,_ in response.links[0].attributes.ListFields()])
+    assert 'total_capacity_gbps' in attribute_names
+    assert abs(response.links[0].attributes.total_capacity_gbps - 200) < 1.e-12
+    assert 'used_capacity_gbps' in attribute_names
+    assert abs(response.links[0].attributes.used_capacity_gbps - 50) < 1.e-12
 
     # ----- Check relation was created ---------------------------------------------------------------------------------
     response = context_client.GetTopology(TopologyId(**TOPOLOGY_ID))
diff --git a/src/device/requirements.in b/src/device/requirements.in
index c81e814603d4c84e0211e3b433fc916b616ecd04..ece761571ec2ff9c3376b1062787d76047d71e7c 100644
--- a/src/device/requirements.in
+++ b/src/device/requirements.in
@@ -20,6 +20,7 @@ cryptography==36.0.2
 Jinja2==3.0.3
 ncclient==0.6.13
 p4runtime==1.3.0
+pandas==1.5.*
 paramiko==2.9.2
 python-json-logger==2.0.2
 #pytz==2021.3
diff --git a/src/forecaster/.gitlab-ci.yml b/src/forecaster/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..09b2f8f4e67db6ce152da608baff6f51279a1dc8
--- /dev/null
+++ b/src/forecaster/.gitlab-ci.yml
@@ -0,0 +1,107 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Build, tag, and push the Docker image to the GitLab Docker registry
+build forecaster:
+  variables:
+    IMAGE_NAME: 'forecaster' # name of the microservice
+    IMAGE_TAG: 'latest' # tag of the container image (production, development, etc)
+  stage: build
+  before_script:
+    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+  script:
+    - docker build -t "$IMAGE_NAME:$IMAGE_TAG" -f ./src/$IMAGE_NAME/Dockerfile .
+    - docker tag "$IMAGE_NAME:$IMAGE_TAG" "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+    - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+  after_script:
+    - docker images --filter="dangling=true" --quiet | xargs -r docker rmi
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)'
+    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
+    - changes:
+      - src/common/**/*.py
+      - proto/*.proto
+      - src/$IMAGE_NAME/**/*.{py,in,yml}
+      - src/$IMAGE_NAME/Dockerfile
+      - src/$IMAGE_NAME/tests/*.py
+      - manifests/${IMAGE_NAME}service.yaml
+      - .gitlab-ci.yml
+
+# Apply unit test to the component
+unit_test forecaster:
+  variables:
+    IMAGE_NAME: 'forecaster' # name of the microservice
+    IMAGE_TAG: 'latest' # tag of the container image (production, development, etc)
+  stage: unit_test
+  needs:
+    - build forecaster
+  before_script:
+    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+    - if docker network list | grep teraflowbridge; then echo "teraflowbridge is already created"; else docker network create -d bridge teraflowbridge; fi
+    - if docker container ls | grep $IMAGE_NAME; then docker rm -f $IMAGE_NAME; else echo "$IMAGE_NAME image is not in the system"; fi
+    - docker container prune -f
+  script:
+    - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG"
+    - docker run --name $IMAGE_NAME -d -p 10040:10040 -v "$PWD/src/$IMAGE_NAME/tests:/opt/results" --network=teraflowbridge $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG
+    - sleep 5
+    - docker ps -a
+    - docker logs $IMAGE_NAME
+    - docker exec -i $IMAGE_NAME bash -c "coverage run -m pytest --log-level=INFO --verbose $IMAGE_NAME/tests/test_unitary.py --junitxml=/opt/results/${IMAGE_NAME}_report.xml"
+    - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing"
+  coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
+  after_script:
+    - docker rm -f $IMAGE_NAME
+    - docker network rm teraflowbridge
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)'
+    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
+    - changes:
+      - src/common/**/*.py
+      - proto/*.proto
+      - src/$IMAGE_NAME/**/*.{py,in,yml}
+      - src/$IMAGE_NAME/Dockerfile
+      - src/$IMAGE_NAME/tests/*.py
+      - src/$IMAGE_NAME/tests/Dockerfile
+      - manifests/${IMAGE_NAME}service.yaml
+      - .gitlab-ci.yml
+  artifacts:
+      when: always
+      reports:
+        junit: src/$IMAGE_NAME/tests/${IMAGE_NAME}_report.xml
+
+## Deployment of the service in Kubernetes Cluster
+#deploy forecaster:
+#  variables:
+#    IMAGE_NAME: 'forecaster' # name of the microservice
+#    IMAGE_TAG: 'latest' # tag of the container image (production, development, etc)
+#  stage: deploy
+#  needs:
+#    - unit test forecaster
+#    # - integ_test execute
+#  script:
+#    - 'sed -i "s/$IMAGE_NAME:.*/$IMAGE_NAME:$IMAGE_TAG/" manifests/${IMAGE_NAME}service.yaml'
+#    - kubectl version
+#    - kubectl get all
+#    - kubectl apply -f "manifests/${IMAGE_NAME}service.yaml"
+#    - kubectl get all
+#  # environment:
+#  #   name: test
+#  #   url: https://example.com
+#  #   kubernetes:
+#  #     namespace: test
+#  rules:
+#    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)'
+#      when: manual    
+#    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
+#      when: manual
diff --git a/src/forecaster/Config.py b/src/forecaster/Config.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d80b8fe62ff2e8313ec6a7b1b0278fea7c16950
--- /dev/null
+++ b/src/forecaster/Config.py
@@ -0,0 +1,21 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+
+# FORECAST_TO_HISTORY_RATIO indicates the size of the trainset.
+# For example a history ratio of 10 would imply that the train-set will be 10 times bigger
+# than the forecast period and the test-set.
+DEFAULT_FORECAST_TO_HISTORY_RATIO = 10
+FORECAST_TO_HISTORY_RATIO = int(os.environ.get('FORECAST_TO_HISTORY_RATIO', DEFAULT_FORECAST_TO_HISTORY_RATIO))
diff --git a/src/forecaster/Dockerfile b/src/forecaster/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..c09ab8007396895df0a9e4a07cf5984b9d662b3e
--- /dev/null
+++ b/src/forecaster/Dockerfile
@@ -0,0 +1,78 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+FROM python:3.9-slim
+
+# Install dependencies
+RUN apt-get --yes --quiet --quiet update && \
+    apt-get --yes --quiet --quiet install wget g++ git && \
+    rm -rf /var/lib/apt/lists/*
+
+# Set Python to show logs as they occur
+ENV PYTHONUNBUFFERED=0
+
+# Download the gRPC health probe
+RUN GRPC_HEALTH_PROBE_VERSION=v0.2.0 && \
+    wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
+    chmod +x /bin/grpc_health_probe
+
+# Get generic Python packages
+RUN python3 -m pip install --upgrade pip
+RUN python3 -m pip install --upgrade setuptools wheel
+RUN python3 -m pip install --upgrade pip-tools
+
+# Get common Python packages
+# Note: this step enables sharing the previous Docker build steps among all the Python components
+WORKDIR /var/teraflow
+COPY common_requirements.in common_requirements.in
+RUN pip-compile --quiet --output-file=common_requirements.txt common_requirements.in
+RUN python3 -m pip install -r common_requirements.txt
+
+# Add common files into working directory
+WORKDIR /var/teraflow/common
+COPY src/common/. ./
+RUN rm -rf proto
+
+# Create proto sub-folder, copy .proto files, and generate Python code
+RUN mkdir -p /var/teraflow/common/proto
+WORKDIR /var/teraflow/common/proto
+RUN touch __init__.py
+COPY proto/*.proto ./
+RUN python3 -m grpc_tools.protoc -I=. --python_out=. --grpc_python_out=. *.proto
+RUN rm *.proto
+RUN find . -type f -exec sed -i -E 's/(import\ .*)_pb2/from . \1_pb2/g' {} \;
+
+# Create component sub-folders, get specific Python packages
+RUN mkdir -p /var/teraflow/forecaster
+WORKDIR /var/teraflow/forecaster
+COPY src/forecaster/requirements.in requirements.in
+RUN pip-compile --quiet --output-file=requirements.txt requirements.in
+RUN python3 -m pip install -r requirements.txt
+
+# Add component files into working directory
+WORKDIR /var/teraflow
+COPY src/context/__init__.py context/__init__.py
+COPY src/context/client/. context/client/
+COPY src/device/__init__.py device/__init__.py
+COPY src/device/client/. device/client/
+COPY src/monitoring/__init__.py monitoring/__init__.py
+COPY src/monitoring/client/. monitoring/client/
+COPY src/service/__init__.py service/__init__.py
+COPY src/service/client/. service/client/
+COPY src/slice/__init__.py slice/__init__.py
+COPY src/slice/client/. slice/client/
+COPY src/forecaster/. forecaster/
+
+# Start the service
+ENTRYPOINT ["python", "-m", "forecaster.service"]
diff --git a/src/forecaster/__init__.py b/src/forecaster/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..38d04994fb0fa1951fb465bc127eb72659dc2eaf
--- /dev/null
+++ b/src/forecaster/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/src/forecaster/client/ForecasterClient.py b/src/forecaster/client/ForecasterClient.py
new file mode 100644
index 0000000000000000000000000000000000000000..17e0beb339f5dbe748a211c4286a98a376ed6084
--- /dev/null
+++ b/src/forecaster/client/ForecasterClient.py
@@ -0,0 +1,63 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import grpc, logging
+from common.Constants import ServiceNameEnum
+from common.Settings import get_service_host, get_service_port_grpc
+from common.proto.forecaster_pb2 import (
+    ForecastLinkCapacityReply, ForecastLinkCapacityRequest,
+    ForecastTopologyCapacityReply, ForecastTopologyCapacityRequest
+)
+from common.proto.forecaster_pb2_grpc import ForecasterServiceStub
+from common.tools.client.RetryDecorator import retry, delay_exponential
+from common.tools.grpc.Tools import grpc_message_to_json_string
+
+LOGGER = logging.getLogger(__name__)
+MAX_RETRIES = 15
+DELAY_FUNCTION = delay_exponential(initial=0.01, increment=2.0, maximum=5.0)
+RETRY_DECORATOR = retry(max_retries=MAX_RETRIES, delay_function=DELAY_FUNCTION, prepare_method_name='connect')
+
+class ForecasterClient:
+    def __init__(self, host=None, port=None):
+        if not host: host = get_service_host(ServiceNameEnum.FORECASTER)
+        if not port: port = get_service_port_grpc(ServiceNameEnum.FORECASTER)
+        self.endpoint = '{:s}:{:s}'.format(str(host), str(port))
+        LOGGER.debug('Creating channel to {:s}...'.format(str(self.endpoint)))
+        self.channel = None
+        self.stub = None
+        self.connect()
+        LOGGER.debug('Channel created')
+
+    def connect(self):
+        self.channel = grpc.insecure_channel(self.endpoint)
+        self.stub = ForecasterServiceStub(self.channel)
+
+    def close(self):
+        if self.channel is not None: self.channel.close()
+        self.channel = None
+        self.stub = None
+
+    @RETRY_DECORATOR
+    def ForecastLinkCapacity(self, request : ForecastLinkCapacityRequest) -> ForecastLinkCapacityReply:
+        LOGGER.debug('ForecastLinkCapacity request: {:s}'.format(grpc_message_to_json_string(request)))
+        response = self.stub.ForecastLinkCapacity(request)
+        LOGGER.debug('ForecastLinkCapacity result: {:s}'.format(grpc_message_to_json_string(response)))
+        return response
+
+    @RETRY_DECORATOR
+    def ForecastTopologyCapacity(self, request : ForecastTopologyCapacityRequest) -> ForecastTopologyCapacityReply:
+        LOGGER.debug('ForecastTopologyCapacity request: {:s}'.format(grpc_message_to_json_string(request)))
+        response = self.stub.ForecastTopologyCapacity(request)
+        LOGGER.debug('ForecastTopologyCapacity result: {:s}'.format(grpc_message_to_json_string(response)))
+        return response
diff --git a/src/forecaster/client/__init__.py b/src/forecaster/client/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1549d9811aa5d1c193a44ad45d0d7773236c0612
--- /dev/null
+++ b/src/forecaster/client/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
diff --git a/src/forecaster/requirements.in b/src/forecaster/requirements.in
new file mode 100644
index 0000000000000000000000000000000000000000..3ed37c5998a550427f987d881e5ce4455b5e1649
--- /dev/null
+++ b/src/forecaster/requirements.in
@@ -0,0 +1,18 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#numpy==1.23.*
+pandas==1.5.*
+#prophet==1.1.*
+scikit-learn==1.1.*
diff --git a/src/forecaster/service/Forecaster.py b/src/forecaster/service/Forecaster.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2b5b4d09af35752ffa221eabaf40b1d22515d32
--- /dev/null
+++ b/src/forecaster/service/Forecaster.py
@@ -0,0 +1,51 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import math, pandas
+from datetime import datetime, timezone
+from statistics import mean
+from sklearn.ensemble import RandomForestRegressor
+from common.proto.monitoring_pb2 import KpiId
+from forecaster.Config import FORECAST_TO_HISTORY_RATIO
+
+def compute_forecast(samples : pandas.DataFrame, kpi_id : KpiId) -> float:
+    kpi_uuid = kpi_id.kpi_id.uuid
+    samples = samples[samples.kpi_id == kpi_uuid].copy()
+
+    num_samples = samples.shape[0]
+    if num_samples <= 0:
+        MSG = 'KpiId({:s}): Wrong number of samples: {:d}'
+        raise Exception(MSG.format(kpi_uuid, num_samples))
+
+    num_samples_test = math.ceil(num_samples / FORECAST_TO_HISTORY_RATIO)
+    if num_samples_test  <= 0:
+        MSG = 'KpiId({:s}): Wrong number of test samples: {:d}'
+        raise Exception(MSG.format(kpi_uuid, num_samples_test ))
+
+    num_samples_train = num_samples - num_samples_test
+    if num_samples_train <= 0:
+        MSG = 'KpiId({:s}): Wrong number of train samples: {:d}'
+        raise Exception(MSG.format(kpi_uuid, num_samples_train))
+
+    samples['timestamp'] = pandas.to_datetime(samples['timestamp']) - datetime(1970, 1, 1, tzinfo=timezone.utc)
+    samples['timestamp'] = samples['timestamp'].dt.total_seconds()
+
+    train_set = samples[0:num_samples_train]
+    test_set  = samples[num_samples_train:num_samples]
+
+    rfr = RandomForestRegressor(n_estimators=600, random_state=42)
+    rfr.fit(train_set.drop(['kpi_id', 'value'], axis=1), train_set['value'])
+    forecast = rfr.predict(test_set.drop(['kpi_id', 'value'], axis=1))
+    avg_forecast = round(mean(forecast), 2)
+    return avg_forecast
diff --git a/src/forecaster/service/ForecasterService.py b/src/forecaster/service/ForecasterService.py
new file mode 100644
index 0000000000000000000000000000000000000000..944ceb01e1429df4e124d28993cf001bb683aeb5
--- /dev/null
+++ b/src/forecaster/service/ForecasterService.py
@@ -0,0 +1,28 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from common.Constants import ServiceNameEnum
+from common.Settings import get_service_port_grpc
+from common.proto.forecaster_pb2_grpc import add_ForecasterServiceServicer_to_server
+from common.tools.service.GenericGrpcService import GenericGrpcService
+from .ForecasterServiceServicerImpl import ForecasterServiceServicerImpl
+
+class ForecasterService(GenericGrpcService):
+    def __init__(self, cls_name: str = __name__) -> None:
+        port = get_service_port_grpc(ServiceNameEnum.FORECASTER)
+        super().__init__(port, cls_name=cls_name)
+        self.forecaster_servicer = ForecasterServiceServicerImpl()
+
+    def install_servicers(self):
+        add_ForecasterServiceServicer_to_server(self.forecaster_servicer, self.server)
diff --git a/src/forecaster/service/ForecasterServiceServicerImpl.py b/src/forecaster/service/ForecasterServiceServicerImpl.py
new file mode 100644
index 0000000000000000000000000000000000000000..41f6a59fd1e99f1ca336b65139eb9399d1aeec1a
--- /dev/null
+++ b/src/forecaster/service/ForecasterServiceServicerImpl.py
@@ -0,0 +1,126 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Dict, List
+import grpc, logging
+from common.method_wrappers.Decorator import MetricsPool, safe_and_metered_rpc_method
+from common.method_wrappers.ServiceExceptions import NotFoundException
+from common.proto.context_pb2 import LinkAttributes, LinkId
+from common.proto.forecaster_pb2 import (
+    ForecastLinkCapacityReply, ForecastLinkCapacityRequest,
+    ForecastTopologyCapacityReply, ForecastTopologyCapacityRequest
+)
+from common.proto.forecaster_pb2_grpc import ForecasterServiceServicer
+from common.proto.kpi_sample_types_pb2 import KpiSampleType
+from common.tools.context_queries.Link import get_link
+from common.tools.context_queries.Topology import get_topology_details
+from common.tools.timestamp.Converters import timestamp_utcnow_to_float
+from context.client.ContextClient import ContextClient
+from forecaster.Config import FORECAST_TO_HISTORY_RATIO
+from forecaster.service.Forecaster import compute_forecast
+from forecaster.service.KpiManager import KpiManager
+
+LOGGER = logging.getLogger(__name__)
+
+METRICS_POOL = MetricsPool('Forecaster', 'RPC')
+
+class ForecasterServiceServicerImpl(ForecasterServiceServicer):
+    def __init__(self) -> None:
+        LOGGER.debug('Creating Servicer...')
+        self._kpi_manager = KpiManager()
+        LOGGER.debug('Servicer Created')
+
+    @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
+    def ForecastLinkCapacity(
+        self, request : ForecastLinkCapacityRequest, context : grpc.ServicerContext
+    ) -> ForecastLinkCapacityReply:
+        forecast_window_seconds = request.forecast_window_seconds
+
+        # history_window_seconds indicates the size of the train-set based on the
+        # requested size of the test-set and the configured history ratio
+        history_window_seconds = FORECAST_TO_HISTORY_RATIO * forecast_window_seconds
+
+        link_id = request.link_id
+        link_uuid = link_id.link_uuid.uuid
+
+        context_client = ContextClient()
+        link = get_link(context_client, link_uuid)
+        if link is None: raise NotFoundException('Link', link_uuid)
+
+        kpi_id_map = self._kpi_manager.get_kpi_ids_from_link_ids([link_id])
+        link_uuid__to__kpi_id = {
+            _link_uuid : _kpi_id
+            for (_link_uuid, _kpi_sample_type), _kpi_id in kpi_id_map.items()
+            if _kpi_sample_type == KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
+        }
+        kpi_id = link_uuid__to__kpi_id[link_uuid]
+
+        end_timestamp   = timestamp_utcnow_to_float()
+        start_timestamp = end_timestamp - history_window_seconds
+        df_historical_data = self._kpi_manager.get_kpi_id_samples([kpi_id], start_timestamp, end_timestamp)
+        forecast_used_capacity_gbps = compute_forecast(df_historical_data, kpi_id)
+
+        reply = ForecastLinkCapacityReply()
+        reply.link_id.link_uuid.uuid      = link_uuid
+        reply.total_capacity_gbps         = link.attributes.total_capacity_gbps
+        reply.current_used_capacity_gbps  = link.attributes.used_capacity_gbps
+        reply.forecast_used_capacity_gbps = forecast_used_capacity_gbps
+        return reply
+
+    @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
+    def ForecastTopologyCapacity(
+        self, request : ForecastTopologyCapacityRequest, context : grpc.ServicerContext
+    ) -> ForecastTopologyCapacityReply:
+        forecast_window_seconds = request.forecast_window_seconds
+
+        # history_window_seconds indicates the size of the train-set based on the
+        # requested size of the test-set and the configured history ratio
+        history_window_seconds = FORECAST_TO_HISTORY_RATIO * forecast_window_seconds
+
+        context_uuid  = request.topology_id.context_id.context_uuid.uuid
+        topology_uuid = request.topology_id.topology_uuid.uuid
+        context_client = ContextClient()
+        topology_details = get_topology_details(context_client, topology_uuid, context_uuid=context_uuid)
+        if topology_details is None:
+            topology_uuid = '{:s}/{:s}'.format(context_uuid, topology_uuid)
+            raise NotFoundException('Topology', topology_uuid)
+
+        link_ids        : List[LinkId]              = list()
+        link_capacities : Dict[str, LinkAttributes] = dict()
+        for link in topology_details.links:
+            link_ids.append(link.link_id)
+            link_capacities[link.link_id.link_uuid.uuid] = link.attributes
+
+        kpi_id_map = self._kpi_manager.get_kpi_ids_from_link_ids(link_ids)
+        link_uuid__to__kpi_id = {
+            _link_id : _kpi_id
+            for (_link_id, _kpi_sample_type), _kpi_id in kpi_id_map.items()
+            if _kpi_sample_type == KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
+        }
+
+        kpi_ids = list(link_uuid__to__kpi_id.values())
+        end_timestamp   = timestamp_utcnow_to_float()
+        start_timestamp = end_timestamp - history_window_seconds
+        df_historical_data = self._kpi_manager.get_kpi_id_samples(kpi_ids, start_timestamp, end_timestamp)
+
+        reply = ForecastTopologyCapacityReply()
+        for link_uuid, kpi_id in link_uuid__to__kpi_id.items():
+            link_attributes = link_capacities[link_uuid]
+            forecast_used_capacity_gbps = compute_forecast(df_historical_data, kpi_id)
+            link_capacity : ForecastLinkCapacityReply = reply.link_capacities.add() # pylint: disable=no-member
+            link_capacity.link_id.link_uuid.uuid      = link_uuid
+            link_capacity.total_capacity_gbps         = link_attributes.total_capacity_gbps
+            link_capacity.current_used_capacity_gbps  = link_attributes.used_capacity_gbps
+            link_capacity.forecast_used_capacity_gbps = forecast_used_capacity_gbps
+        return reply
diff --git a/src/forecaster/service/KpiManager.py b/src/forecaster/service/KpiManager.py
new file mode 100644
index 0000000000000000000000000000000000000000..15864c5936b00b792e83c78934c8cc84286662eb
--- /dev/null
+++ b/src/forecaster/service/KpiManager.py
@@ -0,0 +1,57 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pandas
+from typing import Dict, List, Tuple
+from common.proto.context_pb2 import Empty, LinkId
+from common.proto.monitoring_pb2 import KpiId, KpiQuery
+from monitoring.client.MonitoringClient import MonitoringClient
+
+class KpiManager:
+    def __init__(self) -> None:
+        self._monitoring_client = MonitoringClient()
+
+    def get_kpi_ids_from_link_ids(
+        self, link_ids : List[LinkId]
+    ) -> Dict[Tuple[str, int], KpiId]:
+        link_uuids = {link_id.link_uuid.uuid for link_id in link_ids}
+        kpi_descriptors = self._monitoring_client.GetKpiDescriptorList(Empty())
+        kpi_ids : Dict[Tuple[str, int], KpiId] = {
+            (kpi_descriptor.link_id.link_uuid.uuid, kpi_descriptor.kpi_sample_type) : kpi_descriptor.kpi_id
+            for kpi_descriptor in kpi_descriptors.kpi_descriptor_list
+            if kpi_descriptor.link_id.link_uuid.uuid in link_uuids
+        }
+        return kpi_ids
+
+    def get_kpi_id_samples(
+        self, kpi_ids : List[KpiId], start_timestamp : float, end_timestamp : float
+    ) -> pandas.DataFrame:
+        kpi_query = KpiQuery()
+        for kpi_id in kpi_ids: kpi_query.kpi_ids.add().kpi_id.uuid = kpi_id.kpi_id.uuid
+        kpi_query.start_timestamp.timestamp = start_timestamp   # pylint: disable=no-member
+        kpi_query.end_timestamp.timestamp   = end_timestamp     # pylint: disable=no-member
+        raw_kpi_table = self._monitoring_client.QueryKpiData(kpi_query)
+
+        data : List[Tuple[str, float, float]] = list()
+        for raw_kpi_list in raw_kpi_table.raw_kpi_lists:
+            kpi_uuid = raw_kpi_list.kpi_id.kpi_id.uuid
+            for raw_kpi in raw_kpi_list.raw_kpis:
+                timestamp = raw_kpi.timestamp.timestamp
+                value = float(getattr(raw_kpi.kpi_value, raw_kpi.kpi_value.WhichOneof('value')))
+                data.append((timestamp, kpi_uuid, value))
+
+        df = pandas.DataFrame(data, columns=['timestamp', 'kpi_id', 'value'])
+        df['timestamp'] = pandas.to_datetime(df['timestamp'].astype('int'), unit='s', utc=True)
+        df.sort_values('timestamp', ascending=True, inplace=True)
+        return df
diff --git a/src/forecaster/service/__init__.py b/src/forecaster/service/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1549d9811aa5d1c193a44ad45d0d7773236c0612
--- /dev/null
+++ b/src/forecaster/service/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
diff --git a/src/forecaster/service/__main__.py b/src/forecaster/service/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..780fe5f8f3386b571c37ce64a0b95578e9641110
--- /dev/null
+++ b/src/forecaster/service/__main__.py
@@ -0,0 +1,70 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, signal, sys, threading
+from prometheus_client import start_http_server
+from common.Constants import ServiceNameEnum
+from common.Settings import (
+    ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, get_log_level, get_metrics_port,
+    wait_for_environment_variables)
+from .ForecasterService import ForecasterService
+
+terminate = threading.Event()
+LOGGER : logging.Logger = None
+
+def signal_handler(signal, frame): # pylint: disable=redefined-outer-name
+    LOGGER.warning('Terminate signal received')
+    terminate.set()
+
+def main():
+    global LOGGER # pylint: disable=global-statement
+
+    log_level = get_log_level()
+    logging.basicConfig(level=log_level, format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s")
+    logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING)
+    logging.getLogger('apscheduler.scheduler').setLevel(logging.WARNING)
+    logging.getLogger('monitoring-client').setLevel(logging.WARNING)
+    LOGGER = logging.getLogger(__name__)
+
+    wait_for_environment_variables([
+        get_env_var_name(ServiceNameEnum.CONTEXT,    ENVVAR_SUFIX_SERVICE_HOST     ),
+        get_env_var_name(ServiceNameEnum.CONTEXT,    ENVVAR_SUFIX_SERVICE_PORT_GRPC),
+        get_env_var_name(ServiceNameEnum.MONITORING, ENVVAR_SUFIX_SERVICE_HOST     ),
+        get_env_var_name(ServiceNameEnum.MONITORING, ENVVAR_SUFIX_SERVICE_PORT_GRPC),
+    ])
+
+    signal.signal(signal.SIGINT,  signal_handler)
+    signal.signal(signal.SIGTERM, signal_handler)
+
+    LOGGER.info('Starting...')
+
+    # Start metrics server
+    metrics_port = get_metrics_port()
+    start_http_server(metrics_port)
+
+    # Starting Forecaster service
+    grpc_service = ForecasterService()
+    grpc_service.start()
+
+    # Wait for Ctrl+C or termination signal
+    while not terminate.wait(timeout=1.0): pass
+
+    LOGGER.info('Terminating...')
+    grpc_service.stop()
+
+    LOGGER.info('Bye')
+    return 0
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/src/forecaster/tests/MockService_Dependencies.py b/src/forecaster/tests/MockService_Dependencies.py
new file mode 100644
index 0000000000000000000000000000000000000000..858db17a9e35e30ea93c965815b39a068c696b4b
--- /dev/null
+++ b/src/forecaster/tests/MockService_Dependencies.py
@@ -0,0 +1,49 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+from typing import Union
+from common.Constants import ServiceNameEnum
+from common.Settings import ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name
+from common.proto.context_pb2_grpc import add_ContextServiceServicer_to_server
+from common.proto.monitoring_pb2_grpc import add_MonitoringServiceServicer_to_server
+from common.tests.MockServicerImpl_Context import MockServicerImpl_Context
+from common.tests.MockServicerImpl_Monitoring import MockServicerImpl_Monitoring
+from common.tools.service.GenericGrpcService import GenericGrpcService
+
+LOCAL_HOST = '127.0.0.1'
+
+SERVICE_CONTEXT    = ServiceNameEnum.CONTEXT
+SERVICE_MONITORING = ServiceNameEnum.MONITORING
+
+class MockService_Dependencies(GenericGrpcService):
+    # Mock Service implementing Context, Device, and Service to simplify unitary tests of PathComp
+
+    def __init__(self, bind_port: Union[str, int]) -> None:
+        super().__init__(bind_port, LOCAL_HOST, enable_health_servicer=False, cls_name='MockService')
+
+    # pylint: disable=attribute-defined-outside-init
+    def install_servicers(self):
+        self.context_servicer = MockServicerImpl_Context()
+        add_ContextServiceServicer_to_server(self.context_servicer, self.server)
+
+        self.monitoring_servicer = MockServicerImpl_Monitoring()
+        add_MonitoringServiceServicer_to_server(self.monitoring_servicer, self.server)
+
+    def configure_env_vars(self):
+        os.environ[get_env_var_name(SERVICE_CONTEXT, ENVVAR_SUFIX_SERVICE_HOST     )] = str(self.bind_address)
+        os.environ[get_env_var_name(SERVICE_CONTEXT, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(self.bind_port)
+
+        os.environ[get_env_var_name(SERVICE_MONITORING, ENVVAR_SUFIX_SERVICE_HOST     )] = str(self.bind_address)
+        os.environ[get_env_var_name(SERVICE_MONITORING, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(self.bind_port)
diff --git a/src/forecaster/tests/PrepareTestScenario.py b/src/forecaster/tests/PrepareTestScenario.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d383f616cce90532228efad515ef5a12509403e
--- /dev/null
+++ b/src/forecaster/tests/PrepareTestScenario.py
@@ -0,0 +1,66 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest, os
+from common.Constants import ServiceNameEnum
+from common.Settings import (
+    ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, get_service_port_grpc)
+from context.client.ContextClient import ContextClient
+from forecaster.client.ForecasterClient import ForecasterClient
+from forecaster.service.ForecasterService import ForecasterService
+from monitoring.client.MonitoringClient import MonitoringClient
+from .MockService_Dependencies import MockService_Dependencies
+
+LOCAL_HOST = '127.0.0.1'
+MOCKSERVICE_PORT = 10000
+# avoid privileged ports
+FORECASTER_SERVICE_PORT = MOCKSERVICE_PORT + int(get_service_port_grpc(ServiceNameEnum.FORECASTER))
+os.environ[get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_HOST     )] = str(LOCAL_HOST)
+os.environ[get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(FORECASTER_SERVICE_PORT)
+
+@pytest.fixture(scope='session')
+def mock_service():
+    _service = MockService_Dependencies(MOCKSERVICE_PORT)
+    _service.configure_env_vars()
+    _service.start()
+    yield _service
+    _service.stop()
+
+@pytest.fixture(scope='session')
+def context_client(mock_service : MockService_Dependencies): # pylint: disable=redefined-outer-name
+    _client = ContextClient()
+    yield _client
+    _client.close()
+
+@pytest.fixture(scope='session')
+def monitoring_client(mock_service : MockService_Dependencies): # pylint: disable=redefined-outer-name
+    _client = MonitoringClient()
+    yield _client
+    _client.close()
+
+@pytest.fixture(scope='session')
+def forecaster_service(
+    context_client : ContextClient,         # pylint: disable=redefined-outer-name
+    monitoring_client : MonitoringClient,   # pylint: disable=redefined-outer-name
+):
+    _service = ForecasterService()
+    _service.start()
+    yield _service
+    _service.stop()
+
+@pytest.fixture(scope='session')
+def forecaster_client(forecaster_service : ForecasterService):    # pylint: disable=redefined-outer-name
+    _client = ForecasterClient()
+    yield _client
+    _client.close()
diff --git a/src/forecaster/tests/Tools.py b/src/forecaster/tests/Tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..2fd05f100f1b488aea4e4e1db9502675ac6e9a9f
--- /dev/null
+++ b/src/forecaster/tests/Tools.py
@@ -0,0 +1,125 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, math, pandas
+from typing import Dict
+from common.tools.object_factory.Context import json_context
+from common.tools.object_factory.Device import (
+    json_device_emulated_connect_rules, json_device_emulated_packet_router_disabled, json_device_id
+)
+from common.tools.object_factory.EndPoint import json_endpoint, json_endpoint_id
+from common.tools.object_factory.Link import json_link
+from common.tools.object_factory.Topology import json_topology
+from common.tools.timestamp.Converters import timestamp_datetime_to_int, timestamp_utcnow_to_float
+
+LOGGER = logging.getLogger(__name__)
+
+def read_csv(csv_file : str) -> pandas.DataFrame:
+    LOGGER.info('Using Data File "{:s}"...'.format(csv_file))
+
+    LOGGER.info('Loading...')
+    df = pandas.read_csv(csv_file)
+    LOGGER.info('  DONE')
+
+    LOGGER.info('Parsing and Adapting columns...')
+    if 'dataset.csv' in csv_file:
+        df.rename(columns={'linkid': 'link_id', 'ds': 'timestamp', 'y': 'used_capacity_gbps'}, inplace=True)
+        df[['source', 'destination']] = df['link_id'].str.split('_', expand=True)
+    #elif 'dataset2.csv' in csv_file:
+    #    df.drop(columns=['Unnamed: 0'], inplace=True)
+    #    df.rename(columns={
+    #        'target': 'destination', 'id': 'link_id', 'ds': 'timestamp', 'demandValue': 'used_capacity_gbps'
+    #    }, inplace=True)
+    LOGGER.info('  DONE')
+
+    LOGGER.info('Updating timestamps...')
+    df['timestamp'] = pandas.to_datetime(df['timestamp'])
+    max_timestamp = timestamp_datetime_to_int(df['timestamp'].max())
+    now_timestamp = timestamp_utcnow_to_float()
+    df['timestamp'] = df['timestamp'] + pandas.offsets.Second(now_timestamp - max_timestamp)
+    LOGGER.info('  DONE')
+
+    LOGGER.info('Sorting...')
+    df.sort_values('timestamp', ascending=True, inplace=True)
+    LOGGER.info('  DONE')
+
+    return df
+
+def compose_descriptors(df : pandas.DataFrame, num_client_endpoints : int = 0) -> Dict:
+    devices = dict()
+    links = dict()
+
+    LOGGER.info('Discovering Devices and Links...')
+    #df1.groupby(['A','B']).size().reset_index().rename(columns={0:'count'})
+    df_links = df[['link_id', 'source', 'destination']].drop_duplicates()
+    for row in df_links.itertuples(index=False):
+        #print(row)
+        link_uuid = row.link_id
+        src_device_uuid = row.source
+        dst_device_uuid = row.destination
+        src_port_uuid = row.destination
+        dst_port_uuid = row.source
+
+        if src_device_uuid not in devices:
+            endpoints = set()
+            for num_client_endpoint in range(num_client_endpoints):
+                endpoints.add('client:{:d}'.format(num_client_endpoint))
+            devices[src_device_uuid] = {'id': src_device_uuid, 'endpoints': endpoints}
+        devices[src_device_uuid]['endpoints'].add(src_port_uuid)
+
+        if dst_device_uuid not in devices:
+            endpoints = set()
+            for num_client_endpoint in range(num_client_endpoints):
+                endpoints.add('client:{:d}'.format(num_client_endpoint))
+            devices[dst_device_uuid] = {'id': dst_device_uuid, 'endpoints': endpoints}
+        devices[dst_device_uuid]['endpoints'].add(dst_port_uuid)
+
+        if link_uuid not in links:
+            total_capacity_gbps = df[df.link_id==link_uuid]['used_capacity_gbps'].max()
+            total_capacity_gbps = math.ceil(total_capacity_gbps / 100) * 100 # round up in steps of 100
+            used_capacity_gbps  = df[df.link_id==link_uuid].used_capacity_gbps.iat[-1] # get last value
+            links[link_uuid] = {
+                'id': link_uuid,
+                'src_dev': src_device_uuid, 'src_port': src_port_uuid,
+                'dst_dev': dst_device_uuid, 'dst_port': dst_port_uuid,
+                'total_capacity_gbps': total_capacity_gbps, 'used_capacity_gbps': used_capacity_gbps,
+            }
+    LOGGER.info('  Found {:d} devices and {:d} links...'.format(len(devices), len(links)))
+
+    LOGGER.info('Composing Descriptors...')
+    _context  = json_context('admin', name='admin')
+    _topology = json_topology('admin', name='admin', context_id=_context['context_id'])
+    descriptor = {
+        'dummy_mode': True, # inject the descriptors directly into the Context component
+        'contexts': [_context],
+        'topologies': [_topology],
+        'devices': [
+            json_device_emulated_packet_router_disabled(
+                device_uuid, name=device_uuid, endpoints=[
+                    json_endpoint(json_device_id(device_uuid), endpoint_uuid, 'copper')
+                    for endpoint_uuid in device_data['endpoints']
+                ], config_rules=json_device_emulated_connect_rules([]))
+            for device_uuid,device_data in devices.items()
+        ],
+        'links': [
+            json_link(link_uuid, [
+                json_endpoint_id(json_device_id(link_data['src_dev']), link_data['src_port']),
+                json_endpoint_id(json_device_id(link_data['dst_dev']), link_data['dst_port']),
+            ], name=link_uuid, total_capacity_gbps=link_data['total_capacity_gbps'],
+            used_capacity_gbps=link_data['used_capacity_gbps'])
+            for link_uuid,link_data in links.items()
+        ],
+    }
+    LOGGER.info('  DONE')
+    return descriptor
diff --git a/src/forecaster/tests/__init__.py b/src/forecaster/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1549d9811aa5d1c193a44ad45d0d7773236c0612
--- /dev/null
+++ b/src/forecaster/tests/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
diff --git a/src/forecaster/tests/data/README.md b/src/forecaster/tests/data/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..eaf303847b745e0663e76f300d602cbc048ff599
--- /dev/null
+++ b/src/forecaster/tests/data/README.md
@@ -0,0 +1,33 @@
+# Manual Forecaster test:
+
+- Move to root folder:
+```bash
+cd ~/tfs-ctrl
+```
+
+- Edit `my_deploy.sh` and enable the `monitoring` and the `forecaster` components:
+```bash
+export TFS_COMPONENTS="context device monitoring forecaster pathcomp service slice compute webui load_generator"
+```
+
+- Edit `deploy/tfs.sh` and disable linkerd injection to capture unencrypted traffic.
+```bash
+cp ./manifests/"${COMPONENT}"service.yaml "$MANIFEST"
+#cat ./manifests/"${COMPONENT}"service.yaml | linkerd inject - --proxy-cpu-request "10m" --proxy-cpu-limit "1" --proxy-memory-request "64Mi" --proxy-memory-limit "256Mi" > "$MANIFEST"
+```
+
+- Deploy TeraFlowSDN controller:
+```bash
+source my_deploy.sh
+./deploy/all.sh
+```
+
+- Onboard the topology descriptor `topology.json` through the WebUI.
+
+- Source the runtime environment variables and inject the link utilization KPI values into the Monitoring database:
+```bash
+source tfs_runtime_env_vars.sh
+python src/forecaster/tests/data/inject_samples.py
+```
+
+- Onboard the service descriptor `service.json` through the WebUI.
diff --git a/src/forecaster/tests/data/dataset.csv b/src/forecaster/tests/data/dataset.csv
new file mode 100644
index 0000000000000000000000000000000000000000..476bfccb53f2903a22f709b30c83d404b52c41d1
--- /dev/null
+++ b/src/forecaster/tests/data/dataset.csv
@@ -0,0 +1,328 @@
+"linkid","ds","y"
+"be1.be_de1.de","2005-07-29 11:30:00",13.633721
+"be1.be_fr1.fr","2005-07-29 11:30:00",37.369276
+"be1.be_gr1.gr","2005-07-29 11:30:00",0.023673
+"be1.be_it1.it","2005-07-29 11:30:00",0.251916
+"be1.be_uk1.uk","2005-07-29 11:30:00",26.126846
+"de1.de_be1.be","2005-07-29 11:30:00",41.771397
+"de1.de_gr1.gr","2005-07-29 11:30:00",137.840494
+"de1.de_uk1.uk","2005-07-29 11:30:00",187.909209
+"es1.es_fr1.fr","2005-07-29 11:30:00",9.989878
+"es1.es_it1.it","2005-07-29 11:30:00",15.279216
+"es1.es_pt1.pt","2005-07-29 11:30:00",18.024652
+"fr1.fr_be1.be","2005-07-29 11:30:00",3.491062
+"fr1.fr_es1.es","2005-07-29 11:30:00",27.289934
+"fr1.fr_it1.it","2005-07-29 11:30:00",5.19262
+"fr1.fr_pt1.pt","2005-07-29 11:30:00",2.075046
+"fr1.fr_uk1.uk","2005-07-29 11:30:00",171.856756
+"gr1.gr_be1.be","2005-07-29 11:30:00",0.463852
+"gr1.gr_de1.de","2005-07-29 11:30:00",4070.266255
+"gr1.gr_it1.it","2005-07-29 11:30:00",6.49398
+"it1.it_be1.be","2005-07-29 11:30:00",26.771257
+"it1.it_es1.es","2005-07-29 11:30:00",209.799198
+"it1.it_fr1.fr","2005-07-29 11:30:00",7.919724
+"it1.it_gr1.gr","2005-07-29 11:30:00",4.624628
+"pt1.pt_es1.es","2005-07-29 11:30:00",89.292105
+"pt1.pt_fr1.fr","2005-07-29 11:30:00",0.978255
+"pt1.pt_uk1.uk","2005-07-29 11:30:00",62.342932
+"uk1.uk_be1.be","2005-07-29 11:30:00",11.812727
+"uk1.uk_de1.de","2005-07-29 11:30:00",255.863122
+"uk1.uk_fr1.fr","2005-07-29 11:30:00",233.412507
+"uk1.uk_pt1.pt","2005-07-29 11:30:00",502.236205
+"be1.be_de1.de","2005-06-20 18:15:00",46.947486
+"be1.be_fr1.fr","2005-06-20 18:15:00",73.202652
+"be1.be_gr1.gr","2005-06-20 18:15:00",0.084347
+"be1.be_it1.it","2005-06-20 18:15:00",0.386309
+"be1.be_uk1.uk","2005-06-20 18:15:00",181.377554
+"de1.de_be1.be","2005-06-20 18:15:00",12.337703
+"de1.de_gr1.gr","2005-06-20 18:15:00",166.013822
+"de1.de_uk1.uk","2005-06-20 18:15:00",196.713395
+"es1.es_fr1.fr","2005-06-20 18:15:00",10.50535
+"es1.es_it1.it","2005-06-20 18:15:00",8.637348
+"es1.es_pt1.pt","2005-06-20 18:15:00",20.493486
+"fr1.fr_be1.be","2005-06-20 18:15:00",15.280566
+"fr1.fr_es1.es","2005-06-20 18:15:00",41.105169
+"fr1.fr_it1.it","2005-06-20 18:15:00",6.188476
+"fr1.fr_pt1.pt","2005-06-20 18:15:00",7.292464
+"fr1.fr_uk1.uk","2005-06-20 18:15:00",39.916047
+"gr1.gr_be1.be","2005-06-20 18:15:00",0.987871
+"gr1.gr_de1.de","2005-06-20 18:15:00",3761.528535
+"gr1.gr_it1.it","2005-06-20 18:15:00",0.762946
+"it1.it_be1.be","2005-06-20 18:15:00",11.693967
+"it1.it_es1.es","2005-06-20 18:15:00",20.093962
+"it1.it_fr1.fr","2005-06-20 18:15:00",25.279273
+"it1.it_gr1.gr","2005-06-20 18:15:00",54.110787
+"pt1.pt_es1.es","2005-06-20 18:15:00",12.870606
+"pt1.pt_fr1.fr","2005-06-20 18:15:00",1.294275
+"pt1.pt_uk1.uk","2005-06-20 18:15:00",335.570213
+"uk1.uk_be1.be","2005-06-20 18:15:00",7.096555
+"uk1.uk_de1.de","2005-06-20 18:15:00",356.532161
+"uk1.uk_fr1.fr","2005-06-20 18:15:00",20.45373
+"uk1.uk_pt1.pt","2005-06-20 18:15:00",791.04219
+"be1.be_de1.de","2005-08-29 16:45:00",20.400142
+"be1.be_fr1.fr","2005-08-29 16:45:00",31.346514
+"be1.be_gr1.gr","2005-08-29 16:45:00",0.026822
+"be1.be_it1.it","2005-08-29 16:45:00",0.357069
+"be1.be_uk1.uk","2005-08-29 16:45:00",8.252107
+"de1.de_be1.be","2005-08-29 16:45:00",57.709307
+"de1.de_gr1.gr","2005-08-29 16:45:00",110.602237
+"de1.de_uk1.uk","2005-08-29 16:45:00",239.446965
+"es1.es_fr1.fr","2005-08-29 16:45:00",20.517778
+"es1.es_it1.it","2005-08-29 16:45:00",15.353667
+"es1.es_pt1.pt","2005-08-29 16:45:00",5.643483
+"fr1.fr_be1.be","2005-08-29 16:45:00",4.804849
+"fr1.fr_es1.es","2005-08-29 16:45:00",96.682749
+"fr1.fr_it1.it","2005-08-29 16:45:00",3.330747
+"fr1.fr_pt1.pt","2005-08-29 16:45:00",1.916587
+"fr1.fr_uk1.uk","2005-08-29 16:45:00",28.144199
+"gr1.gr_be1.be","2005-08-29 16:45:00",0.895539
+"gr1.gr_de1.de","2005-08-29 16:45:00",4930.897339
+"gr1.gr_it1.it","2005-08-29 16:45:00",7.983659
+"it1.it_be1.be","2005-08-29 16:45:00",10.327772
+"it1.it_es1.es","2005-08-29 16:45:00",18.97108
+"it1.it_fr1.fr","2005-08-29 16:45:00",98.574706
+"it1.it_gr1.gr","2005-08-29 16:45:00",1.593313
+"pt1.pt_es1.es","2005-08-29 16:45:00",13.32428
+"pt1.pt_fr1.fr","2005-08-29 16:45:00",3.733925
+"pt1.pt_uk1.uk","2005-08-29 16:45:00",51.415678
+"uk1.uk_be1.be","2005-08-29 16:45:00",14.334868
+"uk1.uk_de1.de","2005-08-29 16:45:00",199.272255
+"uk1.uk_fr1.fr","2005-08-29 16:45:00",55.182499
+"uk1.uk_pt1.pt","2005-08-29 16:45:00",652.70225
+"be1.be_de1.de","2005-08-16 07:30:00",27.712568
+"be1.be_fr1.fr","2005-08-16 07:30:00",3.889573
+"be1.be_gr1.gr","2005-08-16 07:30:00",0.086821
+"be1.be_it1.it","2005-08-16 07:30:00",0.005398
+"be1.be_uk1.uk","2005-08-16 07:30:00",4.136991
+"de1.de_be1.be","2005-08-16 07:30:00",41.662646
+"de1.de_gr1.gr","2005-08-16 07:30:00",83.698837
+"de1.de_uk1.uk","2005-08-16 07:30:00",75.24126
+"es1.es_fr1.fr","2005-08-16 07:30:00",2.219729
+"es1.es_it1.it","2005-08-16 07:30:00",7.145139
+"es1.es_pt1.pt","2005-08-16 07:30:00",1.677266
+"fr1.fr_be1.be","2005-08-16 07:30:00",7.666459
+"fr1.fr_es1.es","2005-08-16 07:30:00",13.062805
+"fr1.fr_it1.it","2005-08-16 07:30:00",0.266986
+"fr1.fr_pt1.pt","2005-08-16 07:30:00",0.883506
+"fr1.fr_uk1.uk","2005-08-16 07:30:00",11.742382
+"gr1.gr_be1.be","2005-08-16 07:30:00",0.61562
+"gr1.gr_de1.de","2005-08-16 07:30:00",2001.396054
+"gr1.gr_it1.it","2005-08-16 07:30:00",2.294517
+"it1.it_be1.be","2005-08-16 07:30:00",3.148501
+"it1.it_es1.es","2005-08-16 07:30:00",5.216237
+"it1.it_fr1.fr","2005-08-16 07:30:00",3.382423
+"it1.it_gr1.gr","2005-08-16 07:30:00",4.762001
+"pt1.pt_es1.es","2005-08-16 07:30:00",0.328222
+"pt1.pt_fr1.fr","2005-08-16 07:30:00",0.544094
+"pt1.pt_uk1.uk","2005-08-16 07:30:00",6.946024
+"uk1.uk_be1.be","2005-08-16 07:30:00",14.723145
+"uk1.uk_de1.de","2005-08-16 07:30:00",101.817113
+"uk1.uk_fr1.fr","2005-08-16 07:30:00",13.165427
+"uk1.uk_pt1.pt","2005-08-16 07:30:00",59.307456
+"be1.be_de1.de","2005-05-07 23:45:00",3.00826
+"be1.be_fr1.fr","2005-05-07 23:45:00",18.221967
+"be1.be_gr1.gr","2005-05-07 23:45:00",0.173417
+"be1.be_it1.it","2005-05-07 23:45:00",0.00523
+"be1.be_uk1.uk","2005-05-07 23:45:00",1.61097
+"de1.de_be1.be","2005-05-07 23:45:00",56.899745
+"de1.de_gr1.gr","2005-05-07 23:45:00",631.542367
+"de1.de_uk1.uk","2005-05-07 23:45:00",210.597064
+"es1.es_fr1.fr","2005-05-07 23:45:00",4.532915
+"es1.es_it1.it","2005-05-07 23:45:00",3.393107
+"es1.es_pt1.pt","2005-05-07 23:45:00",12.377514
+"fr1.fr_be1.be","2005-05-07 23:45:00",0.414987
+"fr1.fr_es1.es","2005-05-07 23:45:00",6.300601
+"fr1.fr_it1.it","2005-05-07 23:45:00",3.127358
+"fr1.fr_pt1.pt","2005-05-07 23:45:00",0.651608
+"fr1.fr_uk1.uk","2005-05-07 23:45:00",33.277552
+"gr1.gr_be1.be","2005-05-07 23:45:00",0.199733
+"gr1.gr_de1.de","2005-05-07 23:45:00",933.371627
+"gr1.gr_it1.it","2005-05-07 23:45:00",1.692505
+"it1.it_be1.be","2005-05-07 23:45:00",27.93755
+"it1.it_es1.es","2005-05-07 23:45:00",46.131064
+"it1.it_fr1.fr","2005-05-07 23:45:00",16.271473
+"it1.it_gr1.gr","2005-05-07 23:45:00",739.540984
+"pt1.pt_es1.es","2005-05-07 23:45:00",0.777791
+"pt1.pt_fr1.fr","2005-05-07 23:45:00",0.319394
+"pt1.pt_uk1.uk","2005-05-07 23:45:00",176.696919
+"uk1.uk_be1.be","2005-05-07 23:45:00",1.521
+"uk1.uk_de1.de","2005-05-07 23:45:00",153.659587
+"uk1.uk_fr1.fr","2005-05-07 23:45:00",16.592104
+"uk1.uk_pt1.pt","2005-05-07 23:45:00",292.931055
+"be1.be_de1.de","2005-06-07 00:15:00",11.527804
+"be1.be_fr1.fr","2005-06-07 00:15:00",22.142524
+"be1.be_gr1.gr","2005-06-07 00:15:00",0.005173
+"be1.be_it1.it","2005-06-07 00:15:00",1.885266
+"be1.be_uk1.uk","2005-06-07 00:15:00",1.226966
+"de1.de_be1.be","2005-06-07 00:15:00",82.949555
+"de1.de_gr1.gr","2005-06-07 00:15:00",214.578575
+"de1.de_uk1.uk","2005-06-07 00:15:00",173.876359
+"es1.es_fr1.fr","2005-06-07 00:15:00",19.262246
+"es1.es_it1.it","2005-06-07 00:15:00",3.708902
+"es1.es_pt1.pt","2005-06-07 00:15:00",27.095824
+"fr1.fr_be1.be","2005-06-07 00:15:00",4.848429
+"fr1.fr_es1.es","2005-06-07 00:15:00",20.620681
+"fr1.fr_it1.it","2005-06-07 00:15:00",7.290271
+"fr1.fr_pt1.pt","2005-06-07 00:15:00",4.311982
+"fr1.fr_uk1.uk","2005-06-07 00:15:00",70.120616
+"gr1.gr_be1.be","2005-06-07 00:15:00",1.011151
+"gr1.gr_de1.de","2005-06-07 00:15:00",4235.315598
+"gr1.gr_it1.it","2005-06-07 00:15:00",2.605588
+"it1.it_be1.be","2005-06-07 00:15:00",29.244533
+"it1.it_es1.es","2005-06-07 00:15:00",18.239399
+"it1.it_fr1.fr","2005-06-07 00:15:00",38.556206
+"it1.it_gr1.gr","2005-06-07 00:15:00",25.842485
+"pt1.pt_es1.es","2005-06-07 00:15:00",3.852742
+"pt1.pt_fr1.fr","2005-06-07 00:15:00",0.065172
+"pt1.pt_uk1.uk","2005-06-07 00:15:00",367.749119
+"uk1.uk_be1.be","2005-06-07 00:15:00",12.040352
+"uk1.uk_de1.de","2005-06-07 00:15:00",162.255549
+"uk1.uk_fr1.fr","2005-06-07 00:15:00",58.607545
+"uk1.uk_pt1.pt","2005-06-07 00:15:00",433.376631
+"be1.be_de1.de","2005-06-22 20:30:00",20.497253
+"be1.be_fr1.fr","2005-06-22 20:30:00",20.782852
+"be1.be_gr1.gr","2005-06-22 20:30:00",0.005848
+"be1.be_it1.it","2005-06-22 20:30:00",0.090026
+"be1.be_uk1.uk","2005-06-22 20:30:00",2.056996
+"de1.de_be1.be","2005-06-22 20:30:00",27.225774
+"de1.de_gr1.gr","2005-06-22 20:30:00",272.761011
+"de1.de_uk1.uk","2005-06-22 20:30:00",199.828438
+"es1.es_fr1.fr","2005-06-22 20:30:00",22.522197
+"es1.es_it1.it","2005-06-22 20:30:00",13.393051
+"es1.es_pt1.pt","2005-06-22 20:30:00",5.932286
+"fr1.fr_be1.be","2005-06-22 20:30:00",15.616435
+"fr1.fr_es1.es","2005-06-22 20:30:00",29.365936
+"fr1.fr_it1.it","2005-06-22 20:30:00",2.797562
+"fr1.fr_pt1.pt","2005-06-22 20:30:00",10.604824
+"fr1.fr_uk1.uk","2005-06-22 20:30:00",45.283715
+"gr1.gr_be1.be","2005-06-22 20:30:00",1.087738
+"gr1.gr_de1.de","2005-06-22 20:30:00",4865.371323
+"gr1.gr_it1.it","2005-06-22 20:30:00",3.528006
+"it1.it_be1.be","2005-06-22 20:30:00",11.366813
+"it1.it_es1.es","2005-06-22 20:30:00",7.02441
+"it1.it_fr1.fr","2005-06-22 20:30:00",22.716083
+"it1.it_gr1.gr","2005-06-22 20:30:00",25.769665
+"pt1.pt_es1.es","2005-06-22 20:30:00",8.340278
+"pt1.pt_fr1.fr","2005-06-22 20:30:00",0.860282
+"pt1.pt_uk1.uk","2005-06-22 20:30:00",293.90633
+"uk1.uk_be1.be","2005-06-22 20:30:00",13.314889
+"uk1.uk_de1.de","2005-06-22 20:30:00",168.266278
+"uk1.uk_fr1.fr","2005-06-22 20:30:00",7.017269
+"uk1.uk_pt1.pt","2005-06-22 20:30:00",575.047825
+"be1.be_de1.de","2005-06-12 04:45:00",48.027576
+"be1.be_fr1.fr","2005-06-12 04:45:00",58.392573
+"be1.be_gr1.gr","2005-06-12 04:45:00",0.115611
+"be1.be_it1.it","2005-06-12 04:45:00",0.011134
+"be1.be_uk1.uk","2005-06-12 04:45:00",1.715897
+"de1.de_be1.be","2005-06-12 04:45:00",84.910002
+"de1.de_gr1.gr","2005-06-12 04:45:00",120.996306
+"de1.de_uk1.uk","2005-06-12 04:45:00",155.065369
+"es1.es_fr1.fr","2005-06-12 04:45:00",7.534709
+"es1.es_it1.it","2005-06-12 04:45:00",12.081569
+"es1.es_pt1.pt","2005-06-12 04:45:00",7.131193
+"fr1.fr_be1.be","2005-06-12 04:45:00",2.231369
+"fr1.fr_es1.es","2005-06-12 04:45:00",3.40216
+"fr1.fr_it1.it","2005-06-12 04:45:00",0.943786
+"fr1.fr_pt1.pt","2005-06-12 04:45:00",17.078504
+"fr1.fr_uk1.uk","2005-06-12 04:45:00",35.828258
+"gr1.gr_be1.be","2005-06-12 04:45:00",3.374157
+"gr1.gr_de1.de","2005-06-12 04:45:00",3976.311229
+"gr1.gr_it1.it","2005-06-12 04:45:00",0.046784
+"it1.it_be1.be","2005-06-12 04:45:00",12.296485
+"it1.it_es1.es","2005-06-12 04:45:00",18.296193
+"it1.it_fr1.fr","2005-06-12 04:45:00",4.634694
+"it1.it_gr1.gr","2005-06-12 04:45:00",0.255965
+"pt1.pt_es1.es","2005-06-12 04:45:00",1.012388
+"pt1.pt_fr1.fr","2005-06-12 04:45:00",0.612415
+"pt1.pt_uk1.uk","2005-06-12 04:45:00",254.932438
+"uk1.uk_be1.be","2005-06-12 04:45:00",0.815578
+"uk1.uk_de1.de","2005-06-12 04:45:00",83.263213
+"uk1.uk_fr1.fr","2005-06-12 04:45:00",16.271979
+"uk1.uk_pt1.pt","2005-06-12 04:45:00",324.332057
+"be1.be_de1.de","2005-07-30 02:00:00",40.205525
+"be1.be_fr1.fr","2005-07-30 02:00:00",17.725783
+"be1.be_gr1.gr","2005-07-30 02:00:00",0.013102
+"be1.be_uk1.uk","2005-07-30 02:00:00",8.324196
+"de1.de_be1.be","2005-07-30 02:00:00",29.881858
+"de1.de_gr1.gr","2005-07-30 02:00:00",62.722043
+"de1.de_uk1.uk","2005-07-30 02:00:00",216.185439
+"es1.es_fr1.fr","2005-07-30 02:00:00",22.856548
+"es1.es_it1.it","2005-07-30 02:00:00",11.847197
+"es1.es_pt1.pt","2005-07-30 02:00:00",15.523204
+"fr1.fr_be1.be","2005-07-30 02:00:00",7.983659
+"fr1.fr_es1.es","2005-07-30 02:00:00",3.740785
+"fr1.fr_it1.it","2005-07-30 02:00:00",0.502595
+"fr1.fr_pt1.pt","2005-07-30 02:00:00",0.199565
+"fr1.fr_uk1.uk","2005-07-30 02:00:00",12.856717
+"gr1.gr_be1.be","2005-07-30 02:00:00",1.1077
+"gr1.gr_de1.de","2005-07-30 02:00:00",3570.000337
+"gr1.gr_it1.it","2005-07-30 02:00:00",4.323903
+"it1.it_be1.be","2005-07-30 02:00:00",5.286414
+"it1.it_es1.es","2005-07-30 02:00:00",121.213021
+"it1.it_fr1.fr","2005-07-30 02:00:00",6.819559
+"it1.it_gr1.gr","2005-07-30 02:00:00",1.487767
+"pt1.pt_es1.es","2005-07-30 02:00:00",1.189291
+"pt1.pt_fr1.fr","2005-07-30 02:00:00",1.43311
+"pt1.pt_uk1.uk","2005-07-30 02:00:00",54.263399
+"uk1.uk_be1.be","2005-07-30 02:00:00",2.675709
+"uk1.uk_de1.de","2005-07-30 02:00:00",117.714986
+"uk1.uk_fr1.fr","2005-07-30 02:00:00",35.445042
+"uk1.uk_pt1.pt","2005-07-30 02:00:00",232.448872
+"be1.be_de1.de","2005-05-24 00:00:00",7.462676
+"be1.be_fr1.fr","2005-05-24 00:00:00",46.305493
+"be1.be_gr1.gr","2005-05-24 00:00:00",0.260969
+"be1.be_it1.it","2005-05-24 00:00:00",0.002643
+"be1.be_uk1.uk","2005-05-24 00:00:00",21.759308
+"de1.de_be1.be","2005-05-24 00:00:00",15.698308
+"de1.de_gr1.gr","2005-05-24 00:00:00",2032.807459
+"de1.de_uk1.uk","2005-05-24 00:00:00",550.498265
+"es1.es_fr1.fr","2005-05-24 00:00:00",20.892334
+"es1.es_it1.it","2005-05-24 00:00:00",99.741955
+"es1.es_pt1.pt","2005-05-24 00:00:00",16.16261
+"fr1.fr_be1.be","2005-05-24 00:00:00",2.836755
+"fr1.fr_es1.es","2005-05-24 00:00:00",10.259564
+"fr1.fr_it1.it","2005-05-24 00:00:00",2.967943
+"fr1.fr_pt1.pt","2005-05-24 00:00:00",2.573705
+"fr1.fr_uk1.uk","2005-05-24 00:00:00",40.368708
+"gr1.gr_be1.be","2005-05-24 00:00:00",2.010099
+"gr1.gr_de1.de","2005-05-24 00:00:00",4563.719698
+"gr1.gr_it1.it","2005-05-24 00:00:00",3.384785
+"it1.it_be1.be","2005-05-24 00:00:00",24.598593
+"it1.it_es1.es","2005-05-24 00:00:00",123.836434
+"it1.it_fr1.fr","2005-05-24 00:00:00",113.240327
+"it1.it_gr1.gr","2005-05-24 00:00:00",23.121173
+"pt1.pt_es1.es","2005-05-24 00:00:00",5.621496
+"pt1.pt_fr1.fr","2005-05-24 00:00:00",0.128713
+"pt1.pt_uk1.uk","2005-05-24 00:00:00",382.279278
+"uk1.uk_be1.be","2005-05-24 00:00:00",2.423005
+"uk1.uk_de1.de","2005-05-24 00:00:00",225.272469
+"uk1.uk_fr1.fr","2005-05-24 00:00:00",29.664524
+"uk1.uk_pt1.pt","2005-05-24 00:00:00",515.544459
+"be1.be_de1.de","2005-07-08 22:45:00",41.721914
+"be1.be_fr1.fr","2005-07-08 22:45:00",22.271518
+"be1.be_uk1.uk","2005-07-08 22:45:00",0.565237
+"de1.de_be1.be","2005-07-08 22:45:00",18.116253
+"de1.de_gr1.gr","2005-07-08 22:45:00",84.363659
+"de1.de_uk1.uk","2005-07-08 22:45:00",198.657985
+"es1.es_fr1.fr","2005-07-08 22:45:00",26.077588
+"es1.es_it1.it","2005-07-08 22:45:00",10.734268
+"es1.es_pt1.pt","2005-07-08 22:45:00",3.212886
+"fr1.fr_be1.be","2005-07-08 22:45:00",2.165579
+"fr1.fr_es1.es","2005-07-08 22:45:00",49.61386
+"fr1.fr_it1.it","2005-07-08 22:45:00",7.861918
+"fr1.fr_pt1.pt","2005-07-08 22:45:00",1.42833
+"fr1.fr_uk1.uk","2005-07-08 22:45:00",175.702188
+"gr1.gr_be1.be","2005-07-08 22:45:00",2.705961
+"gr1.gr_de1.de","2005-07-08 22:45:00",4139.070272
+"gr1.gr_it1.it","2005-07-08 22:45:00",2.765173
+"it1.it_be1.be","2005-07-08 22:45:00",22.960183
+"it1.it_es1.es","2005-07-08 22:45:00",191.877562
+"it1.it_fr1.fr","2005-07-08 22:45:00",10.385578
+"it1.it_gr1.gr","2005-07-08 22:45:00",0.905886
+"pt1.pt_es1.es","2005-07-08 22:45:00",6.78166
+"pt1.pt_fr1.fr","2005-07-08 22:45:00",0.162677
+"pt1.pt_uk1.uk","2005-07-08 22:45:00",131.320816
+"uk1.uk_be1.be","2005-07-08 22:45:00",0.836384
+"uk1.uk_de1.de","2005-07-08 22:45:00",547.487643
+"uk1.uk_fr1.fr","2005-07-08 22:45:00",102.387861
+"uk1.uk_pt1.pt","2005-07-08 22:45:00",381.91698
diff --git a/src/forecaster/tests/data/inject_samples.py b/src/forecaster/tests/data/inject_samples.py
new file mode 100644
index 0000000000000000000000000000000000000000..e77cd950828f27e5557ea575d7e9c9b55aabb315
--- /dev/null
+++ b/src/forecaster/tests/data/inject_samples.py
@@ -0,0 +1,59 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import logging, sys
+from common.proto.kpi_sample_types_pb2 import KpiSampleType
+from common.proto.monitoring_pb2 import Kpi, KpiDescriptor
+from common.tools.timestamp.Converters import timestamp_datetime_to_float
+from forecaster.tests.Tools import read_csv
+from monitoring.client.MonitoringClient import MonitoringClient
+
+LOGGER = logging.getLogger(__name__)
+LOGGER.setLevel(logging.DEBUG)
+logging.getLogger('monitoring.client.MonitoringClient').setLevel(logging.INFO)
+
+CSV_DATA_FILE = 'src/forecaster/tests/data/dataset.csv'
+
+def main() -> int:
+    monitoring_client = MonitoringClient()
+    link_uuid_to_kpi_kpi_uuid = dict()
+
+    df = read_csv(CSV_DATA_FILE)
+    for row in df.itertuples(index=False):
+        link_uuid          = row.link_id
+        timestamp          = timestamp_datetime_to_float(row.timestamp)
+        used_capacity_gbps = row.used_capacity_gbps
+
+        if link_uuid in link_uuid_to_kpi_kpi_uuid:
+            kpi_uuid = link_uuid_to_kpi_kpi_uuid[link_uuid]
+        else:
+            kpi_descriptor = KpiDescriptor()
+            kpi_descriptor.kpi_description        = 'Used Capacity in Link: {:s}'.format(link_uuid)
+            kpi_descriptor.kpi_sample_type        = KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
+            kpi_descriptor.link_id.link_uuid.uuid = link_uuid   # pylint: disable=no-member
+            kpi_id = monitoring_client.SetKpi(kpi_descriptor)
+            kpi_uuid = kpi_id.kpi_id.uuid
+            link_uuid_to_kpi_kpi_uuid[link_uuid] = kpi_uuid
+
+        kpi = Kpi()
+        kpi.kpi_id.kpi_id.uuid  = kpi_uuid              # pylint: disable=no-member
+        kpi.timestamp.timestamp = timestamp             # pylint: disable=no-member
+        kpi.kpi_value.floatVal  = used_capacity_gbps    # pylint: disable=no-member
+        monitoring_client.IncludeKpi(kpi)
+
+    return 0
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/src/forecaster/tests/data/service.json b/src/forecaster/tests/data/service.json
new file mode 100644
index 0000000000000000000000000000000000000000..f4482f44eab4ccb43907015cc351c5e44304cf4e
--- /dev/null
+++ b/src/forecaster/tests/data/service.json
@@ -0,0 +1,22 @@
+{
+    "services": [
+        {
+            "service_id": {
+                "context_id": {"context_uuid": {"uuid": "admin"}},
+                "service_uuid": {"uuid": "svc:pt1.pt/client:1==gr1.gr/client:3"}
+            },
+            "service_type": 1,
+            "service_status": {"service_status": 1},
+            "service_endpoint_ids": [
+                {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "client:1"}},
+                {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "client:3"}}
+            ],
+            "service_constraints": [
+                {"sla_capacity": {"capacity_gbps": 25.0}},
+                {"sla_latency": {"e2e_latency_ms": 20.0}},
+                {"schedule": {"start_timestamp": 1700053997, "duration_days": 1.5}}
+            ],
+            "service_config": {"config_rules": []}
+        }
+    ]
+}
diff --git a/src/forecaster/tests/data/topology.json b/src/forecaster/tests/data/topology.json
new file mode 100644
index 0000000000000000000000000000000000000000..f36fbd7d03a93db61a7233e084f60e7680a54606
--- /dev/null
+++ b/src/forecaster/tests/data/topology.json
@@ -0,0 +1,200 @@
+{
+    "contexts": [
+        {"context_id": {"context_uuid": {"uuid": "admin"}}, "name": "admin"}
+    ],
+    "topologies": [
+        {"topology_id": {"topology_uuid": {"uuid": "admin"}, "context_id": {"context_uuid": {"uuid": "admin"}}}, "name": "admin"}
+    ],
+    "devices": [
+        {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "be1.be", "type": "copper"}, {"uuid": "pt1.pt", "type": "copper"}, {"uuid": "uk1.uk", "type": "copper"},
+                {"uuid": "es1.es", "type": "copper"}, {"uuid": "it1.it", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "de1.de", "type": "copper"}, {"uuid": "gr1.gr", "type": "copper"}, {"uuid": "uk1.uk", "type": "copper"},
+                {"uuid": "fr1.fr", "type": "copper"}, {"uuid": "it1.it", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "de1.de", "type": "copper"}, {"uuid": "fr1.fr", "type": "copper"}, {"uuid": "be1.be", "type": "copper"},
+                {"uuid": "pt1.pt", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "uk1.uk", "type": "copper"}, {"uuid": "be1.be", "type": "copper"}, {"uuid": "gr1.gr", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "uk1.uk", "type": "copper"}, {"uuid": "fr1.fr", "type": "copper"}, {"uuid": "es1.es", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "it1.it", "type": "copper"}, {"uuid": "fr1.fr", "type": "copper"}, {"uuid": "pt1.pt", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "es1.es", "type": "copper"}, {"uuid": "fr1.fr", "type": "copper"}, {"uuid": "be1.be", "type": "copper"},
+                {"uuid": "gr1.gr", "type": "copper"}
+            ]}}}
+        ]}},
+        {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "device_type": "emu-packet-router", "device_operational_status": 1, "device_drivers": [0], "device_config": {"config_rules": [
+            {"action": 1, "custom": {"resource_key": "_connect/address", "resource_value": "127.0.0.1"}},
+            {"action": 1, "custom": {"resource_key": "_connect/port", "resource_value": "0"}},
+            {"action": 1, "custom": {"resource_key": "_connect/settings", "resource_value": {"endpoints": [
+                {"uuid": "client:1", "type": "copper"}, {"uuid": "client:2", "type": "copper"}, {"uuid": "client:3", "type": "copper"},
+                {"uuid": "it1.it", "type": "copper"}, {"uuid": "de1.de", "type": "copper"}, {"uuid": "be1.be", "type": "copper"}
+            ]}}}
+        ]}}
+    ],
+    "links": [
+        {"link_id": {"link_uuid": {"uuid": "fr1.fr_be1.be"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 4.804849}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "be1.be"}},
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "fr1.fr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "uk1.uk_fr1.fr"}}, "attributes": {"total_capacity_gbps": 300, "used_capacity_gbps": 55.182499}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "fr1.fr"}},
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "uk1.uk"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "uk1.uk_de1.de"}}, "attributes": {"total_capacity_gbps": 600, "used_capacity_gbps": 199.272255}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "de1.de"}},
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "uk1.uk"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "uk1.uk_be1.be"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 14.334868}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "be1.be"}},
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "uk1.uk"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "pt1.pt_uk1.uk"}}, "attributes": {"total_capacity_gbps": 400, "used_capacity_gbps": 51.415678}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "uk1.uk"}},
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "pt1.pt"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "pt1.pt_fr1.fr"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 3.733925}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "fr1.fr"}},
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "pt1.pt"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "pt1.pt_es1.es"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 13.32428}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "es1.es"}},
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "pt1.pt"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "it1.it_gr1.gr"}}, "attributes": {"total_capacity_gbps": 800, "used_capacity_gbps": 1.593313}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "gr1.gr"}},
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "it1.it"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "it1.it_fr1.fr"}}, "attributes": {"total_capacity_gbps": 200, "used_capacity_gbps": 98.574706}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "fr1.fr"}},
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "it1.it"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "it1.it_es1.es"}}, "attributes": {"total_capacity_gbps": 300, "used_capacity_gbps": 18.97108}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "es1.es"}},
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "it1.it"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "it1.it_be1.be"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 10.327772}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "be1.be"}},
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "it1.it"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "gr1.gr_it1.it"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 7.983659}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "it1.it"}},
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "gr1.gr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "gr1.gr_de1.de"}}, "attributes": {"total_capacity_gbps": 5000, "used_capacity_gbps": 4930.897339}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "de1.de"}},
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "gr1.gr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "gr1.gr_be1.be"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 0.895539}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "be1.be"}},
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "gr1.gr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "fr1.fr_uk1.uk"}}, "attributes": {"total_capacity_gbps": 200, "used_capacity_gbps": 28.144199}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "uk1.uk"}},
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "fr1.fr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "fr1.fr_pt1.pt"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 1.916587}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "pt1.pt"}},
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "fr1.fr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "fr1.fr_it1.it"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 3.330747}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "it1.it"}},
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "fr1.fr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "fr1.fr_es1.es"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 96.682749}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "es1.es"}},
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "fr1.fr"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "es1.es_pt1.pt"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 5.643483}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "pt1.pt"}},
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "es1.es"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "es1.es_it1.it"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 15.353667}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "it1.it"}},
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "es1.es"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "es1.es_fr1.fr"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 20.517778}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "es1.es"}}, "endpoint_uuid": {"uuid": "fr1.fr"}},
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "es1.es"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "de1.de_uk1.uk"}}, "attributes": {"total_capacity_gbps": 600, "used_capacity_gbps": 239.446965}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "uk1.uk"}},
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "de1.de"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "de1.de_gr1.gr"}}, "attributes": {"total_capacity_gbps": 2100, "used_capacity_gbps": 110.602237}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "gr1.gr"}},
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "de1.de"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "de1.de_be1.be"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 57.709307}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "be1.be"}},
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "de1.de"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "uk1.uk_pt1.pt"}}, "attributes": {"total_capacity_gbps": 800, "used_capacity_gbps": 652.70225}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "pt1.pt"}},
+            {"device_id": {"device_uuid": {"uuid": "pt1.pt"}}, "endpoint_uuid": {"uuid": "uk1.uk"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "be1.be_uk1.uk"}}, "attributes": {"total_capacity_gbps": 200, "used_capacity_gbps": 8.252107}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "uk1.uk"}},
+            {"device_id": {"device_uuid": {"uuid": "uk1.uk"}}, "endpoint_uuid": {"uuid": "be1.be"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "be1.be_it1.it"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 0.357069}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "it1.it"}},
+            {"device_id": {"device_uuid": {"uuid": "it1.it"}}, "endpoint_uuid": {"uuid": "be1.be"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "be1.be_de1.de"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 20.400142}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "de1.de"}},
+            {"device_id": {"device_uuid": {"uuid": "de1.de"}}, "endpoint_uuid": {"uuid": "be1.be"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "be1.be_fr1.fr"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 31.346514}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "fr1.fr"}},
+            {"device_id": {"device_uuid": {"uuid": "fr1.fr"}}, "endpoint_uuid": {"uuid": "be1.be"}}
+        ]}, 
+        {"link_id": {"link_uuid": {"uuid": "be1.be_gr1.gr"}}, "attributes": {"total_capacity_gbps": 100, "used_capacity_gbps": 0.026822}, "link_endpoint_ids": [
+            {"device_id": {"device_uuid": {"uuid": "be1.be"}}, "endpoint_uuid": {"uuid": "gr1.gr"}},
+            {"device_id": {"device_uuid": {"uuid": "gr1.gr"}}, "endpoint_uuid": {"uuid": "be1.be"}}
+        ]}
+    ]
+}
diff --git a/src/forecaster/tests/test_unitary.py b/src/forecaster/tests/test_unitary.py
new file mode 100644
index 0000000000000000000000000000000000000000..4d53e68df369113610f414d58664bf67e2e5d0b9
--- /dev/null
+++ b/src/forecaster/tests/test_unitary.py
@@ -0,0 +1,143 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, pandas, pytest #, json
+from typing import Dict, Tuple
+from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME
+from common.proto.context_pb2 import ContextId, TopologyId
+from common.proto.forecaster_pb2 import ForecastLinkCapacityRequest, ForecastTopologyCapacityRequest
+from common.proto.kpi_sample_types_pb2 import KpiSampleType
+from common.proto.monitoring_pb2 import KpiDescriptor
+from common.tools.descriptor.Loader import DescriptorLoader, check_descriptor_load_results, validate_empty_scenario
+from common.tools.object_factory.Context import json_context_id
+from common.tools.object_factory.Topology import json_topology_id
+from context.client.ContextClient import ContextClient
+from forecaster.client.ForecasterClient import ForecasterClient
+from forecaster.tests.Tools import compose_descriptors, read_csv
+from monitoring.client.MonitoringClient import MonitoringClient
+from .MockService_Dependencies import MockService_Dependencies
+from .PrepareTestScenario import ( # pylint: disable=unused-import
+    # be careful, order of symbols is important here!
+    mock_service, forecaster_service, context_client, monitoring_client, forecaster_client)
+
+LOGGER = logging.getLogger(__name__)
+LOGGER.setLevel(logging.DEBUG)
+logging.getLogger('common.tests.InMemoryObjectDatabase').setLevel(logging.INFO)
+logging.getLogger('common.tests.InMemoryTimeSeriesDatabase').setLevel(logging.INFO)
+logging.getLogger('common.tests.MockServicerImpl_Context').setLevel(logging.INFO)
+logging.getLogger('common.tests.MockServicerImpl_Monitoring').setLevel(logging.INFO)
+logging.getLogger('context.client.ContextClient').setLevel(logging.INFO)
+logging.getLogger('monitoring.client.MonitoringClient').setLevel(logging.INFO)
+
+JSON_ADMIN_CONTEXT_ID = json_context_id(DEFAULT_CONTEXT_NAME)
+ADMIN_CONTEXT_ID = ContextId(**JSON_ADMIN_CONTEXT_ID)
+ADMIN_TOPOLOGY_ID = TopologyId(**json_topology_id(DEFAULT_TOPOLOGY_NAME, context_id=JSON_ADMIN_CONTEXT_ID))
+
+CSV_DATA_FILE = 'forecaster/tests/data/dataset.csv'
+#DESC_DATS_FILE = 'forecaster/tests/data/descriptor.json'
+
+@pytest.fixture(scope='session')
+def scenario() -> Tuple[pandas.DataFrame, Dict]:
+    df = read_csv(CSV_DATA_FILE)
+    descriptors = compose_descriptors(df)
+    #with open(DESC_DATS_FILE, 'w', encoding='UTF-8') as f:
+    #    f.write(json.dumps(descriptors))
+    yield df, descriptors
+
+def test_prepare_environment(
+    context_client : ContextClient,             # pylint: disable=redefined-outer-name
+    monitoring_client : MonitoringClient,       # pylint: disable=redefined-outer-name
+    mock_service : MockService_Dependencies,    # pylint: disable=redefined-outer-name
+    scenario : Tuple[pandas.DataFrame, Dict]    # pylint: disable=redefined-outer-name
+) -> None:
+    df, descriptors = scenario
+
+    validate_empty_scenario(context_client)
+    descriptor_loader = DescriptorLoader(descriptors=descriptors, context_client=context_client)
+    results = descriptor_loader.process()
+    check_descriptor_load_results(results, descriptor_loader)
+    descriptor_loader.validate()
+
+    # Verify the scenario has no services/slices
+    response = context_client.GetContext(ADMIN_CONTEXT_ID)
+    assert len(response.service_ids) == 0
+    assert len(response.slice_ids) == 0
+
+    for link in descriptors['links']:
+        link_uuid = link['link_id']['link_uuid']['uuid']
+        kpi_descriptor = KpiDescriptor()
+        kpi_descriptor.kpi_id.kpi_id.uuid     = link_uuid   # pylint: disable=no-member
+        kpi_descriptor.kpi_description        = 'Used Capacity in Link: {:s}'.format(link_uuid)
+        kpi_descriptor.kpi_sample_type        = KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
+        kpi_descriptor.link_id.link_uuid.uuid = link_uuid   # pylint: disable=no-member
+        monitoring_client.SetKpi(kpi_descriptor)
+
+    mock_service.monitoring_servicer.ts_db._data = df.rename(columns={
+        'link_id': 'kpi_uuid',
+        'used_capacity_gbps': 'value'
+    })
+
+def test_forecast_link(
+    context_client : ContextClient,
+    forecaster_client : ForecasterClient,
+):  # pylint: disable=redefined-outer-name
+    topology = context_client.GetTopology(ADMIN_TOPOLOGY_ID)
+    link_id = topology.link_ids[0]
+    forecast_request = ForecastLinkCapacityRequest()
+    forecast_request.link_id.CopyFrom(link_id)                  # pylint: disable=no-member
+    forecast_request.forecast_window_seconds = 10 * 24 * 60 * 60 # 10 days in seconds
+    forecast_reply = forecaster_client.ForecastLinkCapacity(forecast_request)
+    assert forecast_reply.link_id == link_id
+    assert forecast_reply.total_capacity_gbps >= forecast_reply.current_used_capacity_gbps
+    # TODO: validate forecasted values; might be increasing or decreasing
+
+def test_forecast_topology(
+    context_client : ContextClient,
+    forecaster_client : ForecasterClient,
+):  # pylint: disable=redefined-outer-name
+    forecast_request = ForecastTopologyCapacityRequest()
+    forecast_request.topology_id.CopyFrom(ADMIN_TOPOLOGY_ID)    # pylint: disable=no-member
+    forecast_request.forecast_window_seconds = 10 * 24 * 60 * 60 # 10 days in seconds
+    forecast_reply = forecaster_client.ForecastTopologyCapacity(forecast_request)
+
+    topology = context_client.GetTopology(ADMIN_TOPOLOGY_ID)
+    assert len(forecast_reply.link_capacities) == len(topology.link_ids)
+    reply_link_uuid__to__link_capacity = {
+        link_capacity.link_id.link_uuid.uuid : link_capacity
+        for link_capacity in forecast_reply.link_capacities
+    }
+    for link_id in topology.link_ids:
+        link_uuid = link_id.link_uuid.uuid
+        assert link_uuid in reply_link_uuid__to__link_capacity
+        link_capacity_forecast = reply_link_uuid__to__link_capacity[link_uuid]
+        assert link_capacity_forecast.link_id == link_id
+        assert link_capacity_forecast.total_capacity_gbps >= link_capacity_forecast.current_used_capacity_gbps
+        # TODO: validate forecasted values; might be increasing or decreasing
+
+def test_cleanup_environment(
+    context_client : ContextClient,             # pylint: disable=redefined-outer-name
+    scenario : Tuple[pandas.DataFrame, Dict]    # pylint: disable=redefined-outer-name
+) -> None:
+    _, descriptors = scenario
+
+    # Verify the scenario has no services/slices
+    response = context_client.GetContext(ADMIN_CONTEXT_ID)
+    assert len(response.service_ids) == 0
+    assert len(response.slice_ids) == 0
+
+    # Load descriptors and validate the base scenario
+    descriptor_loader = DescriptorLoader(descriptors=descriptors, context_client=context_client)
+    descriptor_loader.validate()
+    descriptor_loader.unload()
+    validate_empty_scenario(context_client)
diff --git a/src/monitoring/service/ManagementDBTools.py b/src/monitoring/service/ManagementDBTools.py
index 6c0a69e0ec6ec22a9fff24a1de073f2df03e2115..d2a63685f17b58c0f92acb4ef9606f3df929b9e0 100644
--- a/src/monitoring/service/ManagementDBTools.py
+++ b/src/monitoring/service/ManagementDBTools.py
@@ -36,12 +36,13 @@ class ManagementDB():
                     kpi_id INTEGER PRIMARY KEY AUTOINCREMENT,
                     kpi_description TEXT,
                     kpi_sample_type INTEGER,
-                    device_id INTEGER,
-                    endpoint_id INTEGER,
-                    service_id INTEGER,
-                    slice_id INTEGER,
-                    connection_id INTEGER,
-                    monitor_flag INTEGER
+                    device_id STRING,
+                    endpoint_id STRING,
+                    service_id STRING,
+                    slice_id STRING,
+                    connection_id STRING,
+                    link_id STRING,
+                    monitor_flag STRING
                 );
             """)
             LOGGER.debug("KPI table created in the ManagementDB")
@@ -87,13 +88,13 @@ class ManagementDB():
             LOGGER.debug(f"Alarm table cannot be created in the ManagementDB. {e}")
             raise Exception
 
-    def insert_KPI(self,kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id):
+    def insert_KPI(self,kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id,link_id):
         try:
             c = self.client.cursor()
-            c.execute("SELECT kpi_id FROM kpi WHERE device_id is ? AND kpi_sample_type is ? AND endpoint_id is ? AND service_id is ? AND slice_id is ? AND connection_id is ?",(device_id,kpi_sample_type,endpoint_id,service_id,slice_id,connection_id))
+            c.execute("SELECT kpi_id FROM kpi WHERE device_id is ? AND kpi_sample_type is ? AND endpoint_id is ? AND service_id is ? AND slice_id is ? AND connection_id is ? AND link_id is ?",(device_id,kpi_sample_type,endpoint_id,service_id,slice_id,connection_id,link_id))
             data=c.fetchone()
             if data is None:
-                c.execute("INSERT INTO kpi (kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id) VALUES (?,?,?,?,?,?,?)", (kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id))
+                c.execute("INSERT INTO kpi (kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id,link_id) VALUES (?,?,?,?,?,?,?,?)", (kpi_description,kpi_sample_type,device_id,endpoint_id,service_id,slice_id,connection_id,link_id))
                 self.client.commit()
                 kpi_id = c.lastrowid
                 LOGGER.debug(f"KPI {kpi_id} succesfully inserted in the ManagementDB")
diff --git a/src/monitoring/service/MetricsDBTools.py b/src/monitoring/service/MetricsDBTools.py
index fd9c092b2d061865cb8c3d625eef8b5d2ef0eab7..ad20b5afaa6d8510e0e39267ef6e1d71782e3e22 100644
--- a/src/monitoring/service/MetricsDBTools.py
+++ b/src/monitoring/service/MetricsDBTools.py
@@ -96,6 +96,7 @@ class MetricsDB():
                     'service_id SYMBOL,' \
                     'slice_id SYMBOL,' \
                     'connection_id SYMBOL,' \
+                    'link_id SYMBOL,' \
                     'timestamp TIMESTAMP,' \
                     'kpi_value DOUBLE)' \
                     'TIMESTAMP(timestamp);'
@@ -106,7 +107,7 @@ class MetricsDB():
             LOGGER.debug(f"Table {self.table} cannot be created. {e}")
             raise Exception
 
-    def write_KPI(self, time, kpi_id, kpi_sample_type, device_id, endpoint_id, service_id, slice_id, connection_id, kpi_value):
+    def write_KPI(self, time, kpi_id, kpi_sample_type, device_id, endpoint_id, service_id, slice_id, connection_id, link_id, kpi_value):
         device_name = self.name_mapping.get_device_name(device_id) or ''
         endpoint_name = self.name_mapping.get_endpoint_name(endpoint_id) or ''
 
@@ -125,7 +126,9 @@ class MetricsDB():
                             'endpoint_name': endpoint_name,
                             'service_id': service_id,
                             'slice_id': slice_id,
-                            'connection_id': connection_id,},
+                            'connection_id': connection_id,
+                            'link_id': link_id,
+                        },
                         columns={
                             'kpi_value': kpi_value},
                         at=datetime.datetime.fromtimestamp(time))
diff --git a/src/monitoring/service/MonitoringServiceServicerImpl.py b/src/monitoring/service/MonitoringServiceServicerImpl.py
index 3bfef65ff0c52f110b9a091e96b6f6b97dfa79cf..608b0bad9d5869cde35be60157fec9e0a6d34c90 100644
--- a/src/monitoring/service/MonitoringServiceServicerImpl.py
+++ b/src/monitoring/service/MonitoringServiceServicerImpl.py
@@ -65,13 +65,14 @@ class MonitoringServiceServicerImpl(MonitoringServiceServicer):
         kpi_service_id = request.service_id.service_uuid.uuid
         kpi_slice_id = request.slice_id.slice_uuid.uuid
         kpi_connection_id = request.connection_id.connection_uuid.uuid
+        kpi_link_id = request.link_id.link_uuid.uuid
         if request.kpi_id.kpi_id.uuid != "":
             response.kpi_id.uuid = request.kpi_id.kpi_id.uuid
             # Here the code to modify an existing kpi
         else:
             data = self.management_db.insert_KPI(
                 kpi_description, kpi_sample_type, kpi_device_id, kpi_endpoint_id, kpi_service_id, kpi_slice_id,
-                kpi_connection_id)
+                kpi_connection_id, kpi_link_id)
             response.kpi_id.uuid = str(data)
         return response
 
@@ -100,6 +101,7 @@ class MonitoringServiceServicerImpl(MonitoringServiceServicer):
             kpiDescriptor.service_id.service_uuid.uuid          = str(kpi_db[5])
             kpiDescriptor.slice_id.slice_uuid.uuid              = str(kpi_db[6])
             kpiDescriptor.connection_id.connection_uuid.uuid    = str(kpi_db[7])
+            kpiDescriptor.link_id.link_uuid.uuid                = str(kpi_db[8])
         return kpiDescriptor
 
     @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
@@ -117,6 +119,7 @@ class MonitoringServiceServicerImpl(MonitoringServiceServicer):
             kpi_descriptor.service_id.service_uuid.uuid         = str(item[5])
             kpi_descriptor.slice_id.slice_uuid.uuid             = str(item[6])
             kpi_descriptor.connection_id.connection_uuid.uuid   = str(item[7])
+            kpi_descriptor.link_id.link_uuid.uuid               = str(item[8])
             kpi_descriptor_list.kpi_descriptor_list.append(kpi_descriptor)
         return kpi_descriptor_list
 
@@ -135,11 +138,12 @@ class MonitoringServiceServicerImpl(MonitoringServiceServicer):
             serviceId = kpiDescriptor.service_id.service_uuid.uuid
             sliceId   = kpiDescriptor.slice_id.slice_uuid.uuid
             connectionId = kpiDescriptor.connection_id.connection_uuid.uuid
+            linkId = kpiDescriptor.link_id.link_uuid.uuid
             time_stamp = request.timestamp.timestamp
             kpi_value = getattr(request.kpi_value, request.kpi_value.WhichOneof('value'))
 
             # Build the structure to be included as point in the MetricsDB
-            self.metrics_db.write_KPI(time_stamp, kpiId, kpiSampleType, deviceId, endpointId, serviceId, sliceId, connectionId, kpi_value)
+            self.metrics_db.write_KPI(time_stamp, kpiId, kpiSampleType, deviceId, endpointId, serviceId, sliceId, connectionId, linkId, kpi_value)
         return Empty()
 
     @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
diff --git a/src/monitoring/tests/test_unitary.py b/src/monitoring/tests/test_unitary.py
index ff19e231e1e6dfee78d5bc1ae71f170990d11609..f2c2215970545e1f6583598bdc5ef88299ba76d2 100644
--- a/src/monitoring/tests/test_unitary.py
+++ b/src/monitoring/tests/test_unitary.py
@@ -22,16 +22,18 @@ from apscheduler.executors.pool import ProcessPoolExecutor
 from apscheduler.schedulers.background import BackgroundScheduler
 from apscheduler.schedulers.base import STATE_STOPPED
 from grpc._channel import _MultiThreadedRendezvous
-from common.Constants import ServiceNameEnum
+from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME, ServiceNameEnum
 from common.Settings import (
     ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, get_service_port_grpc)
-from common.proto.context_pb2 import ConfigActionEnum, DeviceOperationalStatusEnum, EventTypeEnum, DeviceEvent, Device, Empty
+from common.proto.context_pb2 import ConfigActionEnum, Context, ContextId, DeviceOperationalStatusEnum, EventTypeEnum, DeviceEvent, Device, Empty, Topology, TopologyId
 from common.proto.context_pb2_grpc import add_ContextServiceServicer_to_server
 from common.proto.kpi_sample_types_pb2 import KpiSampleType
 from common.proto.monitoring_pb2 import KpiId, KpiDescriptor, SubsDescriptor, SubsList, AlarmID, \
     AlarmDescriptor, AlarmList, KpiDescriptorList, SubsResponse, AlarmResponse, RawKpiTable #, Kpi, KpiList
 from common.tests.MockServicerImpl_Context import MockServicerImpl_Context
 from common.tools.object_factory.ConfigRule import json_config_rule_set
+from common.tools.object_factory.Context import json_context, json_context_id
+from common.tools.object_factory.Topology import json_topology, json_topology_id
 from common.tools.service.GenericGrpcService import GenericGrpcService
 from common.tools.timestamp.Converters import timestamp_utcnow_to_float #, timestamp_string_to_float
 from context.client.ContextClient import ContextClient
@@ -224,6 +226,18 @@ def ingestion_data(kpi_id_int):
                                   kpi_value)
         sleep(0.1)
 
+##################################################
+# Prepare Environment, should be the first test
+##################################################
+
+def test_prepare_environment(
+    context_client : ContextClient,                 # pylint: disable=redefined-outer-name,unused-argument
+):
+    context_id = json_context_id(DEFAULT_CONTEXT_NAME)
+    context_client.SetContext(Context(**json_context(DEFAULT_CONTEXT_NAME)))
+    context_client.SetTopology(Topology(**json_topology(DEFAULT_TOPOLOGY_NAME, context_id=context_id)))
+
+
 ###########################
 # Tests Implementation
 ###########################
@@ -428,10 +442,11 @@ def test_managementdb_tools_kpis(management_db): # pylint: disable=redefined-out
     kpi_service_id     = _create_kpi_request.service_id.service_uuid.uuid       # pylint: disable=maybe-no-member
     kpi_slice_id       = _create_kpi_request.slice_id.slice_uuid.uuid           # pylint: disable=maybe-no-member
     kpi_connection_id  = _create_kpi_request.connection_id.connection_uuid.uuid # pylint: disable=maybe-no-member
+    link_id            = _create_kpi_request.link_id.link_uuid.uuid             # pylint: disable=maybe-no-member
 
     _kpi_id = management_db.insert_KPI(
         kpi_description, kpi_sample_type, kpi_device_id, kpi_endpoint_id, kpi_service_id,
-        kpi_slice_id, kpi_connection_id)
+        kpi_slice_id, kpi_connection_id, link_id)
     assert isinstance(_kpi_id, int)
 
     response = management_db.get_KPI(_kpi_id)
@@ -626,3 +641,14 @@ def test_listen_events(
     events_collector.stop()
 
     LOGGER.warning('test_listen_events end')
+
+
+##################################################
+# Cleanup Environment, should be the last test
+##################################################
+def test_cleanup_environment(
+    context_client : ContextClient,                 # pylint: disable=redefined-outer-name,unused-argument
+):
+    context_id = json_context_id(DEFAULT_CONTEXT_NAME)
+    context_client.RemoveTopology(TopologyId(**json_topology_id(DEFAULT_TOPOLOGY_NAME, context_id=context_id)))
+    context_client.RemoveContext(ContextId(**context_id))
diff --git a/src/pathcomp/.gitlab-ci.yml b/src/pathcomp/.gitlab-ci.yml
index 20ec4e728837b87e061b2ecffa4b7549c658258f..05113d0feab441543d6567f3eb3ab1cacac3a971 100644
--- a/src/pathcomp/.gitlab-ci.yml
+++ b/src/pathcomp/.gitlab-ci.yml
@@ -60,6 +60,7 @@ unit_test pathcomp-backend:
     - if docker network list | grep teraflowbridge; then echo "teraflowbridge is already created"; else docker network create -d bridge teraflowbridge; fi
     - if docker container ls | grep ${IMAGE_NAME}-frontend; then docker rm -f ${IMAGE_NAME}-frontend; else echo "${IMAGE_NAME}-frontend image is not in the system"; fi
     - if docker container ls | grep ${IMAGE_NAME}-backend; then docker rm -f ${IMAGE_NAME}-backend; else echo "${IMAGE_NAME}-backend image is not in the system"; fi
+    - docker container prune -f
   script:
     - docker pull "$CI_REGISTRY_IMAGE/${IMAGE_NAME}-backend:$IMAGE_TAG"
     - docker ps -a
@@ -106,6 +107,7 @@ unit_test pathcomp-frontend:
     - if docker network list | grep teraflowbridge; then echo "teraflowbridge is already created"; else docker network create --driver=bridge teraflowbridge; fi
     - if docker container ls | grep ${IMAGE_NAME}-frontend; then docker rm -f ${IMAGE_NAME}-frontend; else echo "${IMAGE_NAME}-frontend image is not in the system"; fi
     - if docker container ls | grep ${IMAGE_NAME}-backend; then docker rm -f ${IMAGE_NAME}-backend; else echo "${IMAGE_NAME}-backend image is not in the system"; fi
+    - docker container prune -f
   script:
     - docker pull "$CI_REGISTRY_IMAGE/${IMAGE_NAME}-frontend:$IMAGE_TAG"
     - docker pull "$CI_REGISTRY_IMAGE/${IMAGE_NAME}-backend:$IMAGE_TAG"
@@ -131,7 +133,7 @@ unit_test pathcomp-frontend:
     - docker logs ${IMAGE_NAME}-backend
     - >
       docker exec -i ${IMAGE_NAME}-frontend bash -c 
-      "coverage run -m pytest --log-level=INFO --verbose $IMAGE_NAME/frontend/tests/test_unitary.py --junitxml=/opt/results/${IMAGE_NAME}-frontend_report.xml"
+      "coverage run -m pytest --log-level=INFO --verbose --junitxml=/opt/results/${IMAGE_NAME}-frontend_report.xml $IMAGE_NAME/frontend/tests/test_unitary.py $IMAGE_NAME/frontend/tests/test_unitary_pathcomp_forecaster.py"
     - docker exec -i ${IMAGE_NAME}-frontend bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing"
   coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
   after_script:
diff --git a/src/pathcomp/frontend/Config.py b/src/pathcomp/frontend/Config.py
index 714eb7278074ac860caa76dc3ed8b4a40ae9f192..61aa31a8316a67cdba4b214fc0a1ff4b3843b003 100644
--- a/src/pathcomp/frontend/Config.py
+++ b/src/pathcomp/frontend/Config.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import os
+from common.Settings import get_setting
 
 DEFAULT_PATHCOMP_BACKEND_SCHEME  = 'http'
 DEFAULT_PATHCOMP_BACKEND_HOST    = '127.0.0.1'
@@ -37,3 +38,13 @@ PATHCOMP_BACKEND_PORT = int(os.environ.get('PATHCOMP_BACKEND_PORT', backend_port
 
 BACKEND_URL = '{:s}://{:s}:{:d}{:s}'.format(
     PATHCOMP_BACKEND_SCHEME, PATHCOMP_BACKEND_HOST, PATHCOMP_BACKEND_PORT, PATHCOMP_BACKEND_BASEURL)
+
+
+SETTING_NAME_ENABLE_FORECASTER = 'ENABLE_FORECASTER'
+TRUE_VALUES = {'Y', 'YES', 'TRUE', 'T', 'E', 'ENABLE', 'ENABLED'}
+
+def is_forecaster_enabled() -> bool:
+    is_enabled = get_setting(SETTING_NAME_ENABLE_FORECASTER, default=None)
+    if is_enabled is None: return False
+    str_is_enabled = str(is_enabled).upper()
+    return str_is_enabled in TRUE_VALUES
diff --git a/src/pathcomp/frontend/Dockerfile b/src/pathcomp/frontend/Dockerfile
index 08fe50e0f7443ad71ecabf6fdb337539cc07d203..955844cf4d80b39fc0913c9c523fd1267ca0fb1d 100644
--- a/src/pathcomp/frontend/Dockerfile
+++ b/src/pathcomp/frontend/Dockerfile
@@ -66,6 +66,9 @@ COPY src/context/__init__.py context/__init__.py
 COPY src/context/client/. context/client/
 COPY src/device/__init__.py device/__init__.py
 COPY src/device/client/. device/client/
+COPY src/forecaster/. forecaster/
+COPY src/monitoring/__init__.py monitoring/__init__.py
+COPY src/monitoring/client/. monitoring/client/
 COPY src/service/__init__.py service/__init__.py
 COPY src/service/client/. service/client/
 COPY src/slice/__init__.py slice/__init__.py
diff --git a/src/pathcomp/frontend/requirements.in b/src/pathcomp/frontend/requirements.in
index d99d4cd02b1a9fa39633b35d998b228b3b9e9fc7..c96d7425c5d2e32d43559b8b138de8200db40eac 100644
--- a/src/pathcomp/frontend/requirements.in
+++ b/src/pathcomp/frontend/requirements.in
@@ -13,4 +13,6 @@
 # limitations under the License.
 
 
+pandas==1.5.*
 requests==2.27.1
+scikit-learn==1.1.*
diff --git a/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py b/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py
index 784a09e32c2dbb6f6cfcbbbe51048e49ad9a7005..5d3d352d7ddf0638b3d9a3894eb1f9ac3f91c4fb 100644
--- a/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py
+++ b/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py
@@ -13,18 +13,20 @@
 # limitations under the License.
 
 import grpc, logging, threading
-from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME, INTERDOMAIN_TOPOLOGY_NAME
+#from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME, INTERDOMAIN_TOPOLOGY_NAME
 from common.method_wrappers.Decorator import MetricsPool, safe_and_metered_rpc_method
-from common.proto.context_pb2 import ContextId, Empty, TopologyId
+#from common.proto.context_pb2 import ContextId, Empty, TopologyId
 from common.proto.pathcomp_pb2 import PathCompReply, PathCompRequest
 from common.proto.pathcomp_pb2_grpc import PathCompServiceServicer
-from common.tools.context_queries.Device import get_devices_in_topology
-from common.tools.context_queries.Link import get_links_in_topology
-from common.tools.context_queries.InterDomain import is_inter_domain
+#from common.tools.context_queries.Device import get_devices_in_topology
+#from common.tools.context_queries.Link import get_links_in_topology
+#from common.tools.context_queries.InterDomain import is_inter_domain
 from common.tools.grpc.Tools import grpc_message_to_json_string
-from common.tools.object_factory.Context import json_context_id
-from common.tools.object_factory.Topology import json_topology_id
-from context.client.ContextClient import ContextClient
+from pathcomp.frontend.Config import is_forecaster_enabled
+#from common.tools.object_factory.Context import json_context_id
+#from common.tools.object_factory.Topology import json_topology_id
+#from context.client.ContextClient import ContextClient
+from pathcomp.frontend.service.TopologyTools import get_pathcomp_topology_details
 from pathcomp.frontend.service.algorithms.Factory import get_algorithm
 
 LOGGER = logging.getLogger(__name__)
@@ -43,9 +45,7 @@ class PathCompServiceServicerImpl(PathCompServiceServicer):
     def Compute(self, request : PathCompRequest, context : grpc.ServicerContext) -> PathCompReply:
         LOGGER.debug('[Compute] begin ; request = {:s}'.format(grpc_message_to_json_string(request)))
 
-        context_client = ContextClient()
-
-        context_id = json_context_id(DEFAULT_CONTEXT_NAME)
+        #context_client = ContextClient()
         # TODO: improve definition of topologies; for interdomain the current topology design might be not convenient
         #if (len(request.services) == 1) and is_inter_domain(context_client, request.services[0].service_endpoint_ids):
         #    #devices = get_devices_in_topology(context_client, ADMIN_CONTEXT_ID, INTERDOMAIN_TOPOLOGY_NAME)
@@ -56,10 +56,11 @@ class PathCompServiceServicerImpl(PathCompServiceServicer):
         #    # TODO: add contexts, topologies, and membership of devices/links in topologies
         #    #devices = context_client.ListDevices(Empty())
         #    #links = context_client.ListLinks(Empty())
+        #    context_id = json_context_id(DEFAULT_CONTEXT_NAME)
         #    topology_id = json_topology_id(DEFAULT_TOPOLOGY_NAME, context_id)
-        topology_id = json_topology_id(DEFAULT_TOPOLOGY_NAME, context_id)
 
-        topology_details = context_client.GetTopologyDetails(TopologyId(**topology_id))
+        allow_forecasting = is_forecaster_enabled()
+        topology_details = get_pathcomp_topology_details(request, allow_forecasting=allow_forecasting)
 
         algorithm = get_algorithm(request)
         algorithm.add_devices(topology_details.devices)
diff --git a/src/pathcomp/frontend/service/TopologyTools.py b/src/pathcomp/frontend/service/TopologyTools.py
new file mode 100644
index 0000000000000000000000000000000000000000..778cd59acce1eeeeeb1b05bcc3a03f09a9a46a8e
--- /dev/null
+++ b/src/pathcomp/frontend/service/TopologyTools.py
@@ -0,0 +1,98 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, math
+from typing import Dict, Optional
+from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME, ServiceNameEnum
+from common.Settings import ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, find_environment_variables, get_env_var_name
+from common.method_wrappers.ServiceExceptions import InvalidArgumentException
+from common.proto.context_pb2 import Constraint_Schedule, Service, TopologyDetails
+from common.proto.forecaster_pb2 import ForecastLinkCapacityReply, ForecastTopologyCapacityRequest
+from common.proto.pathcomp_pb2 import PathCompRequest
+from common.tools.context_queries.Topology import get_topology_details
+from common.tools.grpc.Tools import grpc_message_to_json_string
+from context.client.ContextClient import ContextClient
+from forecaster.client.ForecasterClient import ForecasterClient
+
+LOGGER = logging.getLogger(__name__)
+
+def get_service_schedule(service : Service) -> Optional[Constraint_Schedule]:
+    for constraint in service.service_constraints:
+        if constraint.WhichOneof('constraint') != 'schedule': continue
+        return constraint.schedule
+    return None
+
+def get_pathcomp_topology_details(request : PathCompRequest, allow_forecasting : bool = False) -> TopologyDetails:
+    context_client = ContextClient()
+    topology_details = get_topology_details(
+        context_client, DEFAULT_TOPOLOGY_NAME, context_uuid=DEFAULT_CONTEXT_NAME, rw_copy=True
+    )
+
+    if len(request.services) == 0:
+        raise InvalidArgumentException('services', grpc_message_to_json_string(request), 'must not be empty')
+
+    if not allow_forecasting:
+        LOGGER.warning('Forecaster is explicitly disabled')
+        return topology_details
+
+    env_vars = find_environment_variables([
+        get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_HOST     ),
+        get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_PORT_GRPC),
+    ])
+    if len(env_vars) != 2:
+        LOGGER.warning('Forecaster is not deployed')
+        return topology_details
+
+    if len(request.services) > 1:
+        LOGGER.warning('Forecaster does not support multiple services')
+        return topology_details
+
+    service = request.services[0]
+    service_schedule = get_service_schedule(service)
+    if service_schedule is None:
+        LOGGER.warning('Service provides no schedule constraint; forecast cannot be used')
+        return topology_details
+
+    #start_timestamp = service_schedule.start_timestamp
+    duration_days = service_schedule.duration_days
+    if float(duration_days) <= 1.e-12:
+        LOGGER.warning('Service schedule constraint does not define a duration; forecast cannot be used')
+        return topology_details
+
+    forecaster_client = ForecasterClient()
+    forecaster_client.connect()
+
+    forecast_request = ForecastTopologyCapacityRequest(
+        topology_id=topology_details.topology_id,
+        forecast_window_seconds = duration_days * 24 * 60 * 60
+    )
+
+    forecast_reply = forecaster_client.ForecastTopologyCapacity(forecast_request)
+
+    forecasted_link_capacities : Dict[str, ForecastLinkCapacityReply] = {
+        link_capacity.link_id.link_uuid.uuid : link_capacity
+        for link_capacity in forecast_reply.link_capacities
+    }
+
+    for link in topology_details.links:
+        link_uuid = link.link_id.link_uuid.uuid
+        forecasted_link_capacity = forecasted_link_capacities.get(link_uuid)
+        if forecasted_link_capacity is None: continue
+        link.attributes.used_capacity_gbps = forecasted_link_capacity.forecast_used_capacity_gbps
+        if link.attributes.total_capacity_gbps < link.attributes.used_capacity_gbps:
+            total_capacity_gbps = link.attributes.used_capacity_gbps
+            total_capacity_gbps = math.ceil(total_capacity_gbps / 100) * 100 # round up in steps of 100
+            link.attributes.total_capacity_gbps = total_capacity_gbps
+
+    return topology_details
diff --git a/src/pathcomp/frontend/service/algorithms/tools/ComposeRequest.py b/src/pathcomp/frontend/service/algorithms/tools/ComposeRequest.py
index e2c6dc13804703d89242b27156763ce887aa4884..02765901ec1084e32fde440ff531f035249fc750 100644
--- a/src/pathcomp/frontend/service/algorithms/tools/ComposeRequest.py
+++ b/src/pathcomp/frontend/service/algorithms/tools/ComposeRequest.py
@@ -118,9 +118,25 @@ def compose_link(grpc_link : Link) -> Dict:
         for link_endpoint_id in grpc_link.link_endpoint_ids
     ]
 
+    total_capacity_gbps, used_capacity_gbps = None, None
+    if grpc_link.HasField('attributes'):
+        attributes = grpc_link.attributes
+        # In proto3, HasField() does not work for scalar fields, using ListFields() instead.
+        attribute_names = set([field.name for field,_ in attributes.ListFields()])
+        if 'total_capacity_gbps' in attribute_names:
+            total_capacity_gbps = attributes.total_capacity_gbps
+        if 'used_capacity_gbps' in attribute_names:
+            used_capacity_gbps = attributes.used_capacity_gbps
+        elif total_capacity_gbps is not None:
+            used_capacity_gbps = total_capacity_gbps
+
+    if total_capacity_gbps is None: total_capacity_gbps = 100
+    if used_capacity_gbps  is None: used_capacity_gbps = 0
+    available_capacity_gbps = total_capacity_gbps - used_capacity_gbps
+
     forwarding_direction = LinkForwardingDirection.UNIDIRECTIONAL.value
-    total_potential_capacity = compose_capacity(200, CapacityUnit.MBPS.value)
-    available_capacity = compose_capacity(200, CapacityUnit.MBPS.value)
+    total_potential_capacity = compose_capacity(total_capacity_gbps, CapacityUnit.GBPS.value)
+    available_capacity = compose_capacity(available_capacity_gbps, CapacityUnit.GBPS.value)
     cost_characteristics = compose_cost_characteristics('linkcost', '1', '0')
     latency_characteristics = compose_latency_characteristics('1')
 
diff --git a/src/pathcomp/frontend/tests/MockService_Dependencies.py b/src/pathcomp/frontend/tests/MockService_Dependencies.py
index e903bc0e028c7ef97f21d7422f37255574547338..858db17a9e35e30ea93c965815b39a068c696b4b 100644
--- a/src/pathcomp/frontend/tests/MockService_Dependencies.py
+++ b/src/pathcomp/frontend/tests/MockService_Dependencies.py
@@ -17,12 +17,15 @@ from typing import Union
 from common.Constants import ServiceNameEnum
 from common.Settings import ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name
 from common.proto.context_pb2_grpc import add_ContextServiceServicer_to_server
+from common.proto.monitoring_pb2_grpc import add_MonitoringServiceServicer_to_server
 from common.tests.MockServicerImpl_Context import MockServicerImpl_Context
+from common.tests.MockServicerImpl_Monitoring import MockServicerImpl_Monitoring
 from common.tools.service.GenericGrpcService import GenericGrpcService
 
 LOCAL_HOST = '127.0.0.1'
 
-SERVICE_CONTEXT = ServiceNameEnum.CONTEXT
+SERVICE_CONTEXT    = ServiceNameEnum.CONTEXT
+SERVICE_MONITORING = ServiceNameEnum.MONITORING
 
 class MockService_Dependencies(GenericGrpcService):
     # Mock Service implementing Context, Device, and Service to simplify unitary tests of PathComp
@@ -35,6 +38,12 @@ class MockService_Dependencies(GenericGrpcService):
         self.context_servicer = MockServicerImpl_Context()
         add_ContextServiceServicer_to_server(self.context_servicer, self.server)
 
+        self.monitoring_servicer = MockServicerImpl_Monitoring()
+        add_MonitoringServiceServicer_to_server(self.monitoring_servicer, self.server)
+
     def configure_env_vars(self):
         os.environ[get_env_var_name(SERVICE_CONTEXT, ENVVAR_SUFIX_SERVICE_HOST     )] = str(self.bind_address)
         os.environ[get_env_var_name(SERVICE_CONTEXT, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(self.bind_port)
+
+        os.environ[get_env_var_name(SERVICE_MONITORING, ENVVAR_SUFIX_SERVICE_HOST     )] = str(self.bind_address)
+        os.environ[get_env_var_name(SERVICE_MONITORING, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(self.bind_port)
diff --git a/src/pathcomp/frontend/tests/PrepareTestScenario.py b/src/pathcomp/frontend/tests/PrepareTestScenario.py
index 387f6aedef1a88559f974a0b792ac1499d42a3f7..8cc06349b3dc61fc62a6711d4cb72c09e39c9a64 100644
--- a/src/pathcomp/frontend/tests/PrepareTestScenario.py
+++ b/src/pathcomp/frontend/tests/PrepareTestScenario.py
@@ -17,16 +17,24 @@ from common.Constants import ServiceNameEnum
 from common.Settings import (
     ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, get_env_var_name, get_service_port_grpc)
 from context.client.ContextClient import ContextClient
+from forecaster.client.ForecasterClient import ForecasterClient
+from forecaster.service.ForecasterService import ForecasterService
+from monitoring.client.MonitoringClient import MonitoringClient
 from pathcomp.frontend.client.PathCompClient import PathCompClient
 from pathcomp.frontend.service.PathCompService import PathCompService
-from pathcomp.frontend.tests.MockService_Dependencies import MockService_Dependencies
+from .MockService_Dependencies import MockService_Dependencies
 
 LOCAL_HOST = '127.0.0.1'
 MOCKSERVICE_PORT = 10000
-PATHCOMP_SERVICE_PORT = MOCKSERVICE_PORT + int(get_service_port_grpc(ServiceNameEnum.PATHCOMP)) # avoid privileged ports
+# avoid privileged ports
+PATHCOMP_SERVICE_PORT = MOCKSERVICE_PORT + int(get_service_port_grpc(ServiceNameEnum.PATHCOMP))
 os.environ[get_env_var_name(ServiceNameEnum.PATHCOMP, ENVVAR_SUFIX_SERVICE_HOST     )] = str(LOCAL_HOST)
 os.environ[get_env_var_name(ServiceNameEnum.PATHCOMP, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(PATHCOMP_SERVICE_PORT)
 
+FORECASTER_SERVICE_PORT = MOCKSERVICE_PORT + int(get_service_port_grpc(ServiceNameEnum.FORECASTER))
+os.environ[get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_HOST     )] = str(LOCAL_HOST)
+os.environ[get_env_var_name(ServiceNameEnum.FORECASTER, ENVVAR_SUFIX_SERVICE_PORT_GRPC)] = str(FORECASTER_SERVICE_PORT)
+
 @pytest.fixture(scope='session')
 def mock_service():
     _service = MockService_Dependencies(MOCKSERVICE_PORT)
@@ -42,8 +50,33 @@ def context_client(mock_service : MockService_Dependencies): # pylint: disable=r
     _client.close()
 
 @pytest.fixture(scope='session')
-def pathcomp_service(context_client : ContextClient):       # pylint: disable=redefined-outer-name
+def monitoring_client(mock_service : MockService_Dependencies): # pylint: disable=redefined-outer-name
+    _client = MonitoringClient()
+    yield _client
+    _client.close()
 
+@pytest.fixture(scope='session')
+def forecaster_service(
+    context_client : ContextClient,         # pylint: disable=redefined-outer-name
+    monitoring_client : MonitoringClient,   # pylint: disable=redefined-outer-name
+):
+    _service = ForecasterService()
+    _service.start()
+    yield _service
+    _service.stop()
+
+@pytest.fixture(scope='session')
+def forecaster_client(forecaster_service : ForecasterService):    # pylint: disable=redefined-outer-name
+    _client = ForecasterClient()
+    yield _client
+    _client.close()
+
+@pytest.fixture(scope='session')
+def pathcomp_service(
+    context_client : ContextClient,         # pylint: disable=redefined-outer-name
+    monitoring_client : MonitoringClient,   # pylint: disable=redefined-outer-name
+    forecaster_client : ForecasterClient,   # pylint: disable=redefined-outer-name
+):
     _service = PathCompService()
     _service.start()
     yield _service
diff --git a/src/pathcomp/frontend/tests/test_unitary.py b/src/pathcomp/frontend/tests/test_unitary.py
index f4e3cbf0f60285b960625a677854c4b7ab4decb9..4d5b3549ba52e3ef448a05c6d137f2a75531f3ea 100644
--- a/src/pathcomp/frontend/tests/test_unitary.py
+++ b/src/pathcomp/frontend/tests/test_unitary.py
@@ -56,7 +56,8 @@ os.environ['PATHCOMP_BACKEND_PORT'] = os.environ.get('PATHCOMP_BACKEND_PORT', ba
 
 from .PrepareTestScenario import ( # pylint: disable=unused-import
     # be careful, order of symbols is important here!
-    mock_service, pathcomp_service, context_client, pathcomp_client)
+    mock_service, context_client, monitoring_client,
+    forecaster_service, forecaster_client, pathcomp_service, pathcomp_client)
 
 LOGGER = logging.getLogger(__name__)
 LOGGER.setLevel(logging.DEBUG)
diff --git a/src/pathcomp/frontend/tests/test_unitary_pathcomp_forecaster.py b/src/pathcomp/frontend/tests/test_unitary_pathcomp_forecaster.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6d39d3177f1a3d2275a6b48d68478c0a37e9d6a
--- /dev/null
+++ b/src/pathcomp/frontend/tests/test_unitary_pathcomp_forecaster.py
@@ -0,0 +1,198 @@
+# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging, os, pandas, pytest
+from typing import Dict, Tuple
+from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME
+from common.proto.context_pb2 import ContextId, TopologyId
+from common.proto.kpi_sample_types_pb2 import KpiSampleType
+from common.proto.monitoring_pb2 import KpiDescriptor
+from common.proto.pathcomp_pb2 import PathCompRequest
+from common.tools.descriptor.Loader import DescriptorLoader, check_descriptor_load_results, validate_empty_scenario
+from common.tools.object_factory.Context import json_context_id
+from common.tools.object_factory.Topology import json_topology_id
+from common.tools.grpc.Tools import grpc_message_to_json
+from common.tools.object_factory.Constraint import (
+    json_constraint_schedule, json_constraint_sla_capacity, json_constraint_sla_latency)
+from common.tools.object_factory.Context import json_context_id
+from common.tools.object_factory.Device import json_device_id
+from common.tools.object_factory.EndPoint import json_endpoint_id
+from common.tools.object_factory.Service import get_service_uuid, json_service_l3nm_planned
+from common.tools.timestamp.Converters import timestamp_utcnow_to_float
+from context.client.ContextClient import ContextClient
+from forecaster.tests.Tools import compose_descriptors, read_csv
+from monitoring.client.MonitoringClient import MonitoringClient
+from pathcomp.frontend.client.PathCompClient import PathCompClient
+from .MockService_Dependencies import MockService_Dependencies
+
+# configure backend environment variables before overwriting them with fixtures to use real backend pathcomp
+DEFAULT_PATHCOMP_BACKEND_SCHEME  = 'http'
+DEFAULT_PATHCOMP_BACKEND_HOST    = '127.0.0.1'
+DEFAULT_PATHCOMP_BACKEND_PORT    = '8081'
+DEFAULT_PATHCOMP_BACKEND_BASEURL = '/pathComp/api/v1/compRoute'
+
+os.environ['PATHCOMP_BACKEND_SCHEME'] = os.environ.get('PATHCOMP_BACKEND_SCHEME', DEFAULT_PATHCOMP_BACKEND_SCHEME)
+os.environ['PATHCOMP_BACKEND_BASEURL'] = os.environ.get('PATHCOMP_BACKEND_BASEURL', DEFAULT_PATHCOMP_BACKEND_BASEURL)
+
+# Find IP:port of backend container as follows:
+# - first check env vars PATHCOMP_BACKEND_HOST & PATHCOMP_BACKEND_PORT
+# - if not set, check env vars PATHCOMPSERVICE_SERVICE_HOST & PATHCOMPSERVICE_SERVICE_PORT_HTTP
+# - if not set, use DEFAULT_PATHCOMP_BACKEND_HOST & DEFAULT_PATHCOMP_BACKEND_PORT
+backend_host = DEFAULT_PATHCOMP_BACKEND_HOST
+backend_host = os.environ.get('PATHCOMPSERVICE_SERVICE_HOST', backend_host)
+os.environ['PATHCOMP_BACKEND_HOST'] = os.environ.get('PATHCOMP_BACKEND_HOST', backend_host)
+
+backend_port = DEFAULT_PATHCOMP_BACKEND_PORT
+backend_port = os.environ.get('PATHCOMPSERVICE_SERVICE_PORT_HTTP', backend_port)
+os.environ['PATHCOMP_BACKEND_PORT'] = os.environ.get('PATHCOMP_BACKEND_PORT', backend_port)
+
+from .PrepareTestScenario import ( # pylint: disable=unused-import
+    # be careful, order of symbols is important here!
+    mock_service, context_client, monitoring_client,
+    forecaster_service, forecaster_client, pathcomp_service, pathcomp_client)
+
+LOGGER = logging.getLogger(__name__)
+LOGGER.setLevel(logging.DEBUG)
+logging.getLogger('common.tests.InMemoryObjectDatabase').setLevel(logging.INFO)
+logging.getLogger('common.tests.InMemoryTimeSeriesDatabase').setLevel(logging.INFO)
+logging.getLogger('common.tests.MockServicerImpl_Context').setLevel(logging.INFO)
+logging.getLogger('common.tests.MockServicerImpl_Monitoring').setLevel(logging.INFO)
+logging.getLogger('context.client.ContextClient').setLevel(logging.INFO)
+logging.getLogger('monitoring.client.MonitoringClient').setLevel(logging.INFO)
+
+JSON_ADMIN_CONTEXT_ID = json_context_id(DEFAULT_CONTEXT_NAME)
+ADMIN_CONTEXT_ID = ContextId(**JSON_ADMIN_CONTEXT_ID)
+ADMIN_TOPOLOGY_ID = TopologyId(**json_topology_id(DEFAULT_TOPOLOGY_NAME, context_id=JSON_ADMIN_CONTEXT_ID))
+
+CSV_DATA_FILE = 'forecaster/tests/data/dataset.csv'
+#DESC_DATS_FILE = 'forecaster/tests/data/descriptor.json'
+
+@pytest.fixture(scope='session')
+def scenario() -> Tuple[pandas.DataFrame, Dict]:
+    df = read_csv(CSV_DATA_FILE)
+    descriptors = compose_descriptors(df, num_client_endpoints=5)
+    #with open(DESC_DATS_FILE, 'w', encoding='UTF-8') as f:
+    #    f.write(json.dumps(descriptors))
+    yield df, descriptors
+
+def test_prepare_environment(
+    context_client : ContextClient,             # pylint: disable=redefined-outer-name
+    monitoring_client : MonitoringClient,       # pylint: disable=redefined-outer-name
+    mock_service : MockService_Dependencies,    # pylint: disable=redefined-outer-name
+    scenario : Tuple[pandas.DataFrame, Dict]    # pylint: disable=redefined-outer-name
+) -> None:
+    df, descriptors = scenario
+
+    validate_empty_scenario(context_client)
+    descriptor_loader = DescriptorLoader(descriptors=descriptors, context_client=context_client)
+    results = descriptor_loader.process()
+    check_descriptor_load_results(results, descriptor_loader)
+    descriptor_loader.validate()
+
+    # Verify the scenario has no services/slices
+    response = context_client.GetContext(ADMIN_CONTEXT_ID)
+    assert len(response.service_ids) == 0
+    assert len(response.slice_ids) == 0
+
+    for link in descriptors['links']:
+        link_uuid = link['link_id']['link_uuid']['uuid']
+        kpi_descriptor = KpiDescriptor()
+        kpi_descriptor.kpi_id.kpi_id.uuid     = link_uuid   # pylint: disable=no-member
+        kpi_descriptor.kpi_description        = 'Used Capacity in Link: {:s}'.format(link_uuid)
+        kpi_descriptor.kpi_sample_type        = KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS
+        kpi_descriptor.link_id.link_uuid.uuid = link_uuid   # pylint: disable=no-member
+        monitoring_client.SetKpi(kpi_descriptor)
+
+    mock_service.monitoring_servicer.ts_db._data = df.rename(columns={
+        'link_id': 'kpi_uuid',
+        'used_capacity_gbps': 'value'
+    })
+
+def test_request_service_shortestpath_forecast(
+    pathcomp_client : PathCompClient,               # pylint: disable=redefined-outer-name
+) -> None:
+    
+    start_timestamp = timestamp_utcnow_to_float()
+    duration_days = 1.5
+
+    endpoint_id_a = json_endpoint_id(json_device_id('pt1.pt'), 'client:1')
+    endpoint_id_z = json_endpoint_id(json_device_id('gr1.gr'), 'client:3')
+    context_uuid = DEFAULT_CONTEXT_NAME
+    service_uuid = get_service_uuid(endpoint_id_a, endpoint_id_z)
+    request_service = json_service_l3nm_planned(
+        service_uuid,
+        context_uuid=context_uuid,
+        endpoint_ids=[endpoint_id_a, endpoint_id_z],
+        constraints=[
+            json_constraint_sla_capacity(25.0),
+            json_constraint_sla_latency(20.0),
+            json_constraint_schedule(start_timestamp, duration_days),
+        ]
+    )
+
+    pathcomp_request = PathCompRequest(services=[request_service])
+    pathcomp_request.shortest_path.Clear()  # hack to select the shortest path algorithm that has no attributes
+
+    pathcomp_reply = pathcomp_client.Compute(pathcomp_request)
+
+    pathcomp_reply = grpc_message_to_json(pathcomp_reply)
+    reply_services = pathcomp_reply['services']
+    reply_connections = pathcomp_reply['connections']
+    assert len(reply_services) >= 1
+    reply_service_ids = {
+        '{:s}/{:s}'.format(
+            svc['service_id']['context_id']['context_uuid']['uuid'],
+            svc['service_id']['service_uuid']['uuid']
+        )
+        for svc in reply_services
+    }
+    # Assert requested service has a reply
+    # It permits having other services not requested (i.e., sub-services)
+    context_service_uuid = '{:s}/{:s}'.format(context_uuid, service_uuid)
+    assert context_service_uuid in reply_service_ids
+
+    reply_connection_service_ids = {
+        '{:s}/{:s}'.format(
+            conn['service_id']['context_id']['context_uuid']['uuid'],
+            conn['service_id']['service_uuid']['uuid']
+        )
+        for conn in reply_connections
+    }
+    # Assert requested service has a connection associated
+    # It permits having other connections not requested (i.e., connections for sub-services)
+    assert context_service_uuid in reply_connection_service_ids
+
+    # TODO: implement other checks. examples:
+    # - request service and reply service endpoints match
+    # - request service and reply connection endpoints match
+    # - reply sub-service and reply sub-connection endpoints match
+    # - others?
+    #for json_service,json_connection in zip(json_services, json_connections):
+
+def test_cleanup_environment(
+    context_client : ContextClient,             # pylint: disable=redefined-outer-name
+    scenario : Tuple[pandas.DataFrame, Dict]    # pylint: disable=redefined-outer-name
+) -> None:
+    _, descriptors = scenario
+
+    # Verify the scenario has no services/slices
+    response = context_client.GetContext(ADMIN_CONTEXT_ID)
+    assert len(response.service_ids) == 0
+    assert len(response.slice_ids) == 0
+
+    # Load descriptors and validate the base scenario
+    descriptor_loader = DescriptorLoader(descriptors=descriptors, context_client=context_client)
+    descriptor_loader.validate()
+    descriptor_loader.unload()
+    validate_empty_scenario(context_client)
diff --git a/src/policy/src/main/java/eu/teraflow/policy/Serializer.java b/src/policy/src/main/java/eu/teraflow/policy/Serializer.java
index e7fb00029f15d82dbe80c8fff13d098ca5b29f30..4f0c600923b6004d3e4e260d9dad973d1396830c 100644
--- a/src/policy/src/main/java/eu/teraflow/policy/Serializer.java
+++ b/src/policy/src/main/java/eu/teraflow/policy/Serializer.java
@@ -1295,6 +1295,10 @@ public class Serializer {
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_TRANSMITTED;
             case BYTES_RECEIVED:
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED;
+            case LINK_TOTAL_CAPACITY_GBPS:
+                return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS;
+            case LINK_USED_CAPACITY_GBPS:
+                return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS;
             case UNKNOWN:
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_UNKNOWN;
             default:
@@ -1312,6 +1316,10 @@ public class Serializer {
                 return KpiSampleType.BYTES_TRANSMITTED;
             case KPISAMPLETYPE_BYTES_RECEIVED:
                 return KpiSampleType.BYTES_RECEIVED;
+            case KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS:
+                return KpiSampleType.LINK_TOTAL_CAPACITY_GBPS;
+            case KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS:
+                return KpiSampleType.LINK_USED_CAPACITY_GBPS;
             case KPISAMPLETYPE_UNKNOWN:
             default:
                 return KpiSampleType.UNKNOWN;
diff --git a/src/policy/src/main/java/eu/teraflow/policy/kpi_sample_types/model/KpiSampleType.java b/src/policy/src/main/java/eu/teraflow/policy/kpi_sample_types/model/KpiSampleType.java
index 38257967703bee1d49ae8b1fc7ef11906690b1aa..12551339d9ac5a11e32fbb48871f4c67e0c4700f 100644
--- a/src/policy/src/main/java/eu/teraflow/policy/kpi_sample_types/model/KpiSampleType.java
+++ b/src/policy/src/main/java/eu/teraflow/policy/kpi_sample_types/model/KpiSampleType.java
@@ -21,5 +21,7 @@ public enum KpiSampleType {
     PACKETS_TRANSMITTED,
     PACKETS_RECEIVED,
     BYTES_TRANSMITTED,
-    BYTES_RECEIVED
+    BYTES_RECEIVED,
+    LINK_TOTAL_CAPACITY_GBPS,
+    LINK_USED_CAPACITY_GBPS
 }
diff --git a/src/policy/src/test/java/eu/teraflow/policy/SerializerTest.java b/src/policy/src/test/java/eu/teraflow/policy/SerializerTest.java
index fb60ef8d1a82417f858fe63845b76b27099f488e..b57bdf10af1bbbfda187e89d7cb3d7951b200db6 100644
--- a/src/policy/src/test/java/eu/teraflow/policy/SerializerTest.java
+++ b/src/policy/src/test/java/eu/teraflow/policy/SerializerTest.java
@@ -2218,6 +2218,12 @@ class SerializerTest {
                 Arguments.of(
                         KpiSampleType.BYTES_RECEIVED,
                         KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED),
+                Arguments.of(
+                        KpiSampleType.LINK_TOTAL_CAPACITY_GBPS,
+                        KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS),
+                Arguments.of(
+                        KpiSampleType.LINK_USED_CAPACITY_GBPS,
+                        KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS),
                 Arguments.of(KpiSampleType.UNKNOWN, KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_UNKNOWN));
     }
 
diff --git a/src/policy/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java b/src/policy/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
index 217672b2e8de2d7c840833a937b0fb04c38a221b..98bdbbd2c364953df27694a839eff3e8f0e1c114 100644
--- a/src/policy/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
+++ b/src/policy/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
@@ -47,6 +47,14 @@ public final class KpiSampleTypes {
      * <code>KPISAMPLETYPE_BYTES_DROPPED = 203;</code>
      */
     KPISAMPLETYPE_BYTES_DROPPED(203),
+    /**
+     * <code>KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS = 301;</code>
+     */
+    KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS(301),
+    /**
+     * <code>KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS = 302;</code>
+     */
+    KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS(302),
     /**
      * <pre>
      *. can be used by both optical and L3 without any issue
@@ -118,6 +126,14 @@ public final class KpiSampleTypes {
      * <code>KPISAMPLETYPE_BYTES_DROPPED = 203;</code>
      */
     public static final int KPISAMPLETYPE_BYTES_DROPPED_VALUE = 203;
+    /**
+     * <code>KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS = 301;</code>
+     */
+    public static final int KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS_VALUE = 301;
+    /**
+     * <code>KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS = 302;</code>
+     */
+    public static final int KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS_VALUE = 302;
     /**
      * <pre>
      *. can be used by both optical and L3 without any issue
@@ -191,6 +207,8 @@ public final class KpiSampleTypes {
         case 201: return KPISAMPLETYPE_BYTES_TRANSMITTED;
         case 202: return KPISAMPLETYPE_BYTES_RECEIVED;
         case 203: return KPISAMPLETYPE_BYTES_DROPPED;
+        case 301: return KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS;
+        case 302: return KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS;
         case 401: return KPISAMPLETYPE_ML_CONFIDENCE;
         case 501: return KPISAMPLETYPE_OPTICAL_SECURITY_STATUS;
         case 601: return KPISAMPLETYPE_L3_UNIQUE_ATTACK_CONNS;
diff --git a/src/webui/service/templates/link/detail.html b/src/webui/service/templates/link/detail.html
index 8ca7faee3e1871d11b819c6ca95668e654041f8c..864d0cdb20b8098a100c9c5f32ca637c20af9aac 100644
--- a/src/webui/service/templates/link/detail.html
+++ b/src/webui/service/templates/link/detail.html
@@ -79,6 +79,29 @@
 </div>
 
 
+<b>Attributes:</b>
+<table class="table table-striped table-hover">
+    <thead>
+        <tr>
+            <th scope="col">Key</th>
+            <th scope="col">Value</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for field_descriptor, field_value in link.attributes.ListFields() %}
+        <tr>
+            <td>
+                {{ field_descriptor.name }}
+            </td>
+            <td>
+                {{ field_value }}
+            </td>
+        </tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+
 <!-- Modal -->
 <div class="modal fade" id="deleteModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
     aria-labelledby="staticBackdropLabel" aria-hidden="true">
diff --git a/src/webui/service/templates/service/detail.html b/src/webui/service/templates/service/detail.html
index 414aa19d0165ed7138f277005d5573c9242daefb..9c27bc99a106c96c352b4623d4c5bd91839c6726 100644
--- a/src/webui/service/templates/service/detail.html
+++ b/src/webui/service/templates/service/detail.html
@@ -166,6 +166,15 @@
                 {% if constraint.sla_availability.all_active %}all{% else %}single{% endif %}-active
             </td>
         </tr>
+        {% elif constraint.WhichOneof('constraint')=='schedule' %}
+        <tr>
+            <td>Schedule</td>
+            <td>-</td>
+            <td>
+                Start time: {{ constraint.schedule.start_timestamp }},
+                Duration: {{ constraint.schedule.duration_days }} days.
+            </td>
+        </tr>
         {% elif constraint.WhichOneof('constraint')=='sla_isolation' %}
         <tr>
             <td>SLA Isolation</td>
diff --git a/src/webui/service/templates/slice/detail.html b/src/webui/service/templates/slice/detail.html
index 13b69defeb95f66aba47a4aa78f98631ca8cc367..bbca18ecc8b91d99dbb6960e547b1ed7c47fe038 100644
--- a/src/webui/service/templates/slice/detail.html
+++ b/src/webui/service/templates/slice/detail.html
@@ -166,6 +166,15 @@
                 {% if constraint.sla_availability.all_active %}all{% else %}single{% endif %}-active
             </td>
         </tr>
+        {% elif constraint.WhichOneof('constraint')=='schedule' %}
+        <tr>
+            <td>Schedule</td>
+            <td>-</td>
+            <td>
+                Start time: {{ constraint.schedule.start_timestamp }},
+                Duration: {{ constraint.schedule.duration_days }} days.
+            </td>
+        </tr>
         {% elif constraint.WhichOneof('constraint')=='sla_isolation' %}
         <tr>
             <td>SLA Isolation</td>
diff --git a/src/ztp/src/main/java/eu/teraflow/ztp/Serializer.java b/src/ztp/src/main/java/eu/teraflow/ztp/Serializer.java
index e1196bb600382327295966cfe7bb4539f543b976..0a1bd670ab92dfe4d4ddccf3bf513cc88fa3f3c1 100644
--- a/src/ztp/src/main/java/eu/teraflow/ztp/Serializer.java
+++ b/src/ztp/src/main/java/eu/teraflow/ztp/Serializer.java
@@ -764,6 +764,10 @@ public class Serializer {
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_TRANSMITTED;
             case BYTES_RECEIVED:
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED;
+            case LINK_TOTAL_CAPACITY_GBPS:
+                return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS;
+            case LINK_USED_CAPACITY_GBPS:
+                return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS;
             case UNKNOWN:
                 return KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_UNKNOWN;
             default:
@@ -781,6 +785,10 @@ public class Serializer {
                 return KpiSampleType.BYTES_TRANSMITTED;
             case KPISAMPLETYPE_BYTES_RECEIVED:
                 return KpiSampleType.BYTES_RECEIVED;
+            case KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS:
+                return KpiSampleType.LINK_TOTAL_CAPACITY_GBPS;
+            case KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS:
+                return KpiSampleType.LINK_USED_CAPACITY_GBPS;
             case KPISAMPLETYPE_UNKNOWN:
             default:
                 return KpiSampleType.UNKNOWN;
diff --git a/src/ztp/src/main/java/eu/teraflow/ztp/kpi_sample_types/model/KpiSampleType.java b/src/ztp/src/main/java/eu/teraflow/ztp/kpi_sample_types/model/KpiSampleType.java
index 701fd9021c5d72c817b8a374742c268233037ffd..6db242d9d1f1eda6aa221c6bdd60e4fd94045abf 100644
--- a/src/ztp/src/main/java/eu/teraflow/ztp/kpi_sample_types/model/KpiSampleType.java
+++ b/src/ztp/src/main/java/eu/teraflow/ztp/kpi_sample_types/model/KpiSampleType.java
@@ -21,5 +21,7 @@ public enum KpiSampleType {
     PACKETS_TRANSMITTED,
     PACKETS_RECEIVED,
     BYTES_TRANSMITTED,
-    BYTES_RECEIVED
+    BYTES_RECEIVED,
+    LINK_TOTAL_CAPACITY_GBPS,
+    LINK_USED_CAPACITY_GBPS
 }
diff --git a/src/ztp/src/test/java/eu/teraflow/ztp/SerializerTest.java b/src/ztp/src/test/java/eu/teraflow/ztp/SerializerTest.java
index 7d6bb54c3806641f83b45818a4ba132ff9195d73..8441255f3a569662324a38aef7a025ddae815049 100644
--- a/src/ztp/src/test/java/eu/teraflow/ztp/SerializerTest.java
+++ b/src/ztp/src/test/java/eu/teraflow/ztp/SerializerTest.java
@@ -1177,6 +1177,12 @@ class SerializerTest {
                 Arguments.of(
                         KpiSampleType.BYTES_RECEIVED,
                         KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_BYTES_RECEIVED),
+                Arguments.of(
+                        KpiSampleType.LINK_TOTAL_CAPACITY_GBPS,
+                        KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS),
+                Arguments.of(
+                        KpiSampleType.LINK_USED_CAPACITY_GBPS,
+                        KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS),
                 Arguments.of(KpiSampleType.UNKNOWN, KpiSampleTypes.KpiSampleType.KPISAMPLETYPE_UNKNOWN));
     }
 
diff --git a/src/ztp/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java b/src/ztp/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
index 217672b2e8de2d7c840833a937b0fb04c38a221b..98bdbbd2c364953df27694a839eff3e8f0e1c114 100644
--- a/src/ztp/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
+++ b/src/ztp/target/generated-sources/grpc/kpi_sample_types/KpiSampleTypes.java
@@ -47,6 +47,14 @@ public final class KpiSampleTypes {
      * <code>KPISAMPLETYPE_BYTES_DROPPED = 203;</code>
      */
     KPISAMPLETYPE_BYTES_DROPPED(203),
+    /**
+     * <code>KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS = 301;</code>
+     */
+    KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS(301),
+    /**
+     * <code>KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS = 302;</code>
+     */
+    KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS(302),
     /**
      * <pre>
      *. can be used by both optical and L3 without any issue
@@ -118,6 +126,14 @@ public final class KpiSampleTypes {
      * <code>KPISAMPLETYPE_BYTES_DROPPED = 203;</code>
      */
     public static final int KPISAMPLETYPE_BYTES_DROPPED_VALUE = 203;
+    /**
+     * <code>KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS = 301;</code>
+     */
+    public static final int KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS_VALUE = 301;
+    /**
+     * <code>KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS = 302;</code>
+     */
+    public static final int KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS_VALUE = 302;
     /**
      * <pre>
      *. can be used by both optical and L3 without any issue
@@ -191,6 +207,8 @@ public final class KpiSampleTypes {
         case 201: return KPISAMPLETYPE_BYTES_TRANSMITTED;
         case 202: return KPISAMPLETYPE_BYTES_RECEIVED;
         case 203: return KPISAMPLETYPE_BYTES_DROPPED;
+        case 301: return KPISAMPLETYPE_LINK_TOTAL_CAPACITY_GBPS;
+        case 302: return KPISAMPLETYPE_LINK_USED_CAPACITY_GBPS;
         case 401: return KPISAMPLETYPE_ML_CONFIDENCE;
         case 501: return KPISAMPLETYPE_OPTICAL_SECURITY_STATUS;
         case 601: return KPISAMPLETYPE_L3_UNIQUE_ATTACK_CONNS;