It is best to start with Live Mapping by going through a simple example.
Our example works with a hypothetical boiler, which is an object composed from other structured objects, such as controllers, a drum, level indicator, and pipes. Some of these objects are composed as well. The precise structure is described in the code comments below.
Note: The code snippets demonstrated here are for OPC Classic. In OPC-UA, the principles of Live Mapping are the same, but the actual attributes and pieces of code are slightly different. If you want to use Live Mapping with OPC-UA, please use the explanation here as a guidance, and refer to the UAConsoleLiveMapping example in the product instead.
The simulated boiler data are provided by the sample OPC server that ships with the OPC Data Client product.
The code below is taken from the ConsoleLiveMapping example that ships with the product (file Boiler.cs):
Define Live Mapping |
Copy Code
|
---|---|
using System; using OpcLabs.BaseLib.LiveMapping; using OpcLabs.EasyOpc.DataAccess; using OpcLabs.EasyOpc.DataAccess.LiveMapping; using OpcLabs.EasyOpc.DataAccess.LiveMapping.Extensions; namespace ConsoleLiveMapping { // The Boiler and its constituents are described in our application // domain terms, the way we want to work with them. // Attributes are used to describe the correspondence between our // types and members, and OPC nodes. // This is how the boiler looks in OPC address space: // - Boiler #1 // - CC1001 (CustomController) // - ControlOut // - Description // - Input1 // - Input2 // - Input3 // - Drum1001 (BoilerDrum) // - LIX001 (LevelIndicator) // - Output // - FC1001 (FlowController) // - ControlOut // - Measurement // - SetPoint // - LC1001 (LevelController) // - ControlOut // - Measurement // - SetPoint // - Pipe1001 (BoilerInputPipe) // - FTX001 (FlowTransmitter) // - Output // - Pipe1002 (BoilerOutputPipe) // - FTX002 (FlowTransmitter) // - Output [DAType] class Boiler { // Specifying BrowsePath-s here only because we have named the // class members differently from OPC node names. [DANode(BrowsePath = "Pipe1001")] public BoilerInputPipe InputPipe = new BoilerInputPipe(); [DANode(BrowsePath = "Drum1001")] public BoilerDrum Drum = new BoilerDrum(); [DANode(BrowsePath = "Pipe1002")] public BoilerOutputPipe OutputPipe = new BoilerOutputPipe(); [DANode(BrowsePath = "FC1001")] public FlowController FlowController = new FlowController(); [DANode(BrowsePath = "LC1001")] public LevelController LevelController = new LevelController(); [DANode(BrowsePath = "CC1001")] public CustomController CustomController = new CustomController(); } [DAType] class BoilerInputPipe { // Specifying BrowsePath-s here only because we have named the // class members differently from OPC node names. [DANode(BrowsePath = "FTX001")] public FlowTransmitter FlowTransmitter1 = new FlowTransmitter(); [DANode(BrowsePath = "ValveX001")] public Valve Valve = new Valve(); } [DAType] class BoilerDrum { // Specifying BrowsePath-s here only because we have named the // class members differently from OPC node names. [DANode(BrowsePath = "LIX001")] public LevelIndicator LevelIndicator = new LevelIndicator(); } [DAType] class BoilerOutputPipe { // Specifying BrowsePath-s here only because we have named the // class members differently from OPC node names. [DANode(BrowsePath = "FTX002")] public FlowTransmitter FlowTransmitter2 = new FlowTransmitter(); } [DAType] class FlowController : GenericController { } [DAType] class LevelController : GenericController { } [DAType] class CustomController { [DANode, DAItem] public double Input1 { get; set; } [DANode, DAItem] public double Input2 { get; set; } [DANode, DAItem] public double Input3 { get; set; } [DANode, DAItem(Operations = DAItemMappingOperations.ReadAndSubscribe)] // no OPC writing public double ControlOut { get; set; } [DANode, DAItem] public string Description { get; set; } } [DAType] class FlowTransmitter : GenericSensor { } [DAType] class Valve : GenericActuator { } [DAType] class LevelIndicator : GenericSensor { } [DAType] class GenericController { [DANode, DAItem(Operations = DAItemMappingOperations.ReadAndSubscribe)] // no OPC writing public double Measurement { get; set; } [DANode, DAItem] public double SetPoint { get; set; } [DANode, DAItem(Operations = DAItemMappingOperations.ReadAndSubscribe)] // no OPC writing public double ControlOut { get; set; } } [DAType] class GenericSensor { // Meta-members are filled in by information collected during // mapping, and allow access to it later from your code. // Alternatively, you can derive your class from DAMappedNode, // which will bring in many meta-members automatically. [MetaMember("NodeDescriptor")] public DANodeDescriptor NodeDescriptor { get; set; } [DANode, DAItem(Operations = DAItemMappingOperations.ReadAndSubscribe)] // no OPC writing public double Output { get { return _output; } set { _output = value; Console.WriteLine("Sensor \"{0}\" output is now {1}.", NodeDescriptor, value); } } private double _output; } [DAType] class GenericActuator { [DANode, DAItem] public double Input { get; set; } } } |
Notice the setter for the GenericSensor.Output property in the code above. It contains your application logic for “what to do when sensor’s output changes” (in our example, we simply display the sensor’s identification and the new value). Similarly, you can write logic that handles other OPC item changes, or – in the opposite direction – provides values to be written to the OPC items.
Let us now use the above mapping definitions to actually perform some meaningful tasks.
First, we create an instance of our logical “boiler” object, and using the DAClientMapper object (for mapping to OPC Data Access), we map it to OPC. The type mapping definition for the Boiler does not contain information about which OPC server to use, and where in its address space (tree of items) can the boiler items be found. This is intentional - although such information could have been specified directly on the Boiler class, it would hamper the reusability of such class. We therefore provide that information at the moment of mapping, by specifying it inside the new DAMappingContext passed to the mapper. Similarly, we specify the requested update rate.
The code pieces below are taken from the ConsoleLiveMapping example that ships with the product (file Program.cs).
Perform Live Mapping |
Copy Code
|
---|---|
varboiler1 = new Boiler(); varmapper = new DAClientMapper(); mapper.Map(boiler1, new DAMappingContext { ServerDescriptor = "OPCLabs.KitServer.2", // local OPC server // The NodeDescriptor below determines where in the OPC address // space we want to map our data to. NodeDescriptor = new DANodeDescriptor { BrowsePath = "/Boilers/Boiler #1" }, // Requested update rate (for subscriptions): GroupParameters = 1000, }); |
With the mapping already in place, we can easily do various operations on the mapped objects. For example, we can read in all data of the boiler, and the values will appear directly in the properties of our Boiler object:
Read data with Live Mapping |
Copy Code
|
---|---|
Console.WriteLine(); Console.WriteLine("Reading all data of the boiler..."); mapper.Read(); Console.WriteLine("Drum level is: {0}", boiler1.Drum.LevelIndicator.Output); |
You can also modify properties of the level controller, e.g. change its setpoint by setting the SetPoint property, and then send all writeable values to level controller through OPC:
Write data with Live Mapping |
Copy Code
|
---|---|
Console.WriteLine(); Console.WriteLine("Writing new setpoint value..."); boiler1.LevelController.SetPoint = 50.0; mapper.WriteTarget(boiler1.LevelController, /*recurse:*/false); |
If you want to subscribe to changes occurring in the boiler, and have the properties in your object automatically updates with new values, you can do it as below. When, e.g., an output of any sensor changes, the Output property will be set on the corresponding object, and the code that (in our example) displays the new value will run.
Subscribe to data with Live Mapping |
Copy Code
|
---|---|
Console.WriteLine(); Console.WriteLine("Subscribing to boiler data changes..."); mapper.Subscribe(/*active:*/true); Thread.Sleep(30 * 1000); Console.WriteLine(); Console.WriteLine("Unsubscribing from boiler data changes..."); mapper.Subscribe(/*active:*/false); |
You can already see some of the benefits of the live mapping in this simple example. The code that you write can focus on the logic of the application, and works directly with members of your .NET objects. You do not have to clutter your code with OPC-specific information such as server and item IDs. It also gets very easy to reuse the mapped types: If you had more boilers that are represented identically in the OPC address space, you can simply create more Boiler objects and map them all, without a need to change anything in the definition of the Boiler class.