-
Notifications
You must be signed in to change notification settings - Fork 28
5. Extending CARP Mobile Sensing
The main purpose of the CARP Mobile Sensing framework is to allow for extension with domain-specific studies, triggers, tasks, measures, probes, and data types. This is done by implementing classes that inherit from the (abstract) classes in the library.
There are a couple of ways to extend the library according to the following scenarios:
-
Adding new triggers to be used as part of a
StudyProtocol
-
Adding new sampling capabilities by implementing a
SamplingPackage
-
Adding a new data management and backend support by creating a
DataManager
. -
Creating data and privacy transformer schemas that can transform CARP data to other formats, including privacy protecting them, by implementing a
TransformerSchema
.
Triggers are a central part of a StudyProtocol
and CAMS allows creating your own triggers and add them to the framework. This is done by the following steps:
- Define a new
Trigger
(or several). - Define a
TriggerExecutor
for each new trigger. - Define and register a
TriggerFactory
that knows how to create the correctTriggerExecutor
based on a specific trigger.
Any trigger should extend the TriggerConfiguration
class and implement domain-specific fields that configure this trigger. In the example below, we have defined a RemoteTrigger
that listens to resources on a server identified by a URI, and triggers when this resource is available.
/// A trigger that triggers based on an event from a remote server.
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class RemoteTrigger extends TriggerConfiguration {
RemoteTrigger({
required this.uri,
this.interval = const Duration(minutes: 10),
}) : super();
/// The URI of the resource to listen to.
String uri;
/// How often should we check the server?
/// Default is every 10 minutes.
Duration interval;
@override
Function get fromJsonFunction => _$RemoteTriggerFromJson;
factory RemoteTrigger.fromJson(Map<String, dynamic> json) =>
FromJsonFactory().fromJson(json) as RemoteTrigger;
@override
Map<String, dynamic> toJson() => _$RemoteTriggerToJson(this);
}
Trigger configurations can be part of a study protocol. If you want your trigger configurations to be serializable to JSON, add JSON serialization according to the carp_serializable
package. This entails
- annotated with
@JsonSerializable
, - implement the three JSON serializable methods;
fromJsonFunction
,fromJson
,toJson
, - register the configuration's
fromJsonFunction
in theFromJsonFactory
, e.g. in theonRegistry()
callback function of theTriggerFactory
, as we shall see below.
Each trigger needs a corresponding TriggerExecutor
to execute the trigger on sampling runtime. An example of a RemoteTriggerExecutor
executor is shown below. Every Executor
in CAMS can implement runtime behavior on init, start, stop, restart, and dispose. The abstract class TriggerExecutor
is a convenient class to use for the implementation of a trigger executor since it has default implementations of all methods. Hence, you only need to override the lifecycle methods you need something to happen. Typically -- and as shown below -- the most relevant method to override is the onStart()
method which is called when sensing is started. In this method, you will implement the trigger logic. In the RemoteTriggerExecutor
the trigger starts a periodic timer that regularly checks the resources specified by the URI in the trigger configuration. If there is a resource available, it triggers data sampling (the trigger's task) by calling the onTrigger()
callback method.
/// Executes a [RemoteTrigger], i.e. check if there is a resource on
/// the server and triggers if so.
class RemoteTriggerExecutor extends TriggerExecutor<RemoteTrigger> {
final client = Client();
@override
Future<bool> onStart() async {
// Set up a periodic timer to look for a resource at the specified URI
timer = Timer.periodic(configuration!.interval, (_) async {
var response = await client.get(
Uri.parse(Uri.encodeFull(configuration!.uri)),
);
if (response.statusCode == HttpStatus.ok) {
// If there is a resource at the specified URI, then trigger this executor
onTrigger();
}
});
return true;
}
}
The last step is to define a TriggerFactory
that knows how to map triggers to their executors on runtime. An example of the RemoteTriggerFactory
is shown below. Note that a factory can handle multiple triggers, as defined in the set of trigger types
it supports. The main method of the factory is the create
method that can create the correct trigger executor for the specified trigger.
/// A [TriggerFactory] for all remote triggers.
class RemoteTriggerFactory implements TriggerFactory {
@override
Set<Type> types = {
// Note that this factory might support several types of remote triggers
RemoteTrigger,
};
@override
TriggerExecutor<TriggerConfiguration> create(TriggerConfiguration trigger) {
if (trigger is RemoteTrigger) return RemoteTriggerExecutor();
return ImmediateTriggerExecutor();
}
@override
void onRegister() {
// When registering this factory add the triggers to the JSON serialization
FromJsonFactory().registerAll([RemoteTrigger(uri: 'uri')]);
}
}
Note that in the onRegister()
callback function, the trigger configurations are added to the FromJsonFactory()
. This allows trigger configurations defined in a study protocol to be serialized to/from JSON.
The last step is to register this trigger factory with CAMS. This is done by calling:
ExecutorFactory().registerTriggerFactory(RemoteTriggerFactory());
This is typically done when initializing CAMS before any protocol is made or loaded.
If you want to add new sensing capabilities you would basically create a new SamplingPackage
. A sampling package implements the following classes:
-
SamplingPackage
- the overall specification of what measures this package can collect. -
SamplingSchema
- specifies sampling configurations. -
Data
- specifies the data model of the measures collected. -
Probe
- implements the runtime of data collection. -
DeviceManager
- specifies how an external device is managed (if any).
Not all sampling packages use an external device and are hence simpler to implement since they can extend the default SmartphoneDeviceManager
.
In the following, we will use the DeviceSamplingPackage
as an example of a sampling package that does not use any external device.
The first step is that your new sampling package should implement the SamplingPackage
interface:
class DeviceSamplingPackage extends SmartphoneSamplingPackage {
/// Measure type for collection of basic device information
static const String DEVICE_INFORMATION =
'${CarpDataTypes.CARP_NAMESPACE}.deviceinformation';
/// Measure type for collection of free physical and virtual memory.
static const String FREE_MEMORY =
'${CarpDataTypes.CARP_NAMESPACE}.freememory';
/// Measure type for collection of battery level and charging status.
static const String BATTERY_STATE =
'${CarpDataTypes.CARP_NAMESPACE}.batterystate';
/// Measure type for collection of screen events (on/off/unlocked).
static const String SCREEN_EVENT =
'${CarpDataTypes.CARP_NAMESPACE}.screenevent';
/// Measure type for collection of the time zone of the device.
static const String TIMEZONE = '${CarpDataTypes.CARP_NAMESPACE}.timezone';
@override
DataTypeSamplingSchemeMap get samplingSchemes =>
DataTypeSamplingSchemeMap.from([
DataTypeSamplingScheme(CamsDataTypeMetaData(
type: DEVICE_INFORMATION,
displayName: "Device Information",
timeType: DataTimeType.POINT,
dataEventType: DataEventType.ONE_TIME,
)),
DataTypeSamplingScheme(
CamsDataTypeMetaData(
type: FREE_MEMORY,
displayName: "Free Memory",
timeType: DataTimeType.POINT,
),
IntervalSamplingConfiguration(
interval: const Duration(minutes: 1),
)),
DataTypeSamplingScheme(CamsDataTypeMetaData(
type: BATTERY_STATE,
displayName: "Battery State",
timeType: DataTimeType.POINT,
)),
DataTypeSamplingScheme(CamsDataTypeMetaData(
type: SCREEN_EVENT,
displayName: "Screen Events",
timeType: DataTimeType.POINT,
)),
DataTypeSamplingScheme(CamsDataTypeMetaData(
type: TIMEZONE,
displayName: "Device Timezone",
timeType: DataTimeType.POINT,
dataEventType: DataEventType.ONE_TIME,
)),
]);
@override
void onRegister() {
FromJsonFactory().registerAll([
DeviceInformation(),
BatteryState(),
FreeMemory(),
ScreenEvent(),
Timezone(''),
]);
}
@override
Probe? create(String type) => switch (type) {
DEVICE_INFORMATION => DeviceProbe(),
FREE_MEMORY => MemoryProbe(),
BATTERY_STATE => BatteryProbe(),
TIMEZONE => TimezoneProbe(),
SCREEN_EVENT => (Platform.isAndroid) ? ScreenProbe() : null,
_ => null,
};
}
The measures supported by this package are listed as static strings like FREE_MEMORY
defining the type like dk.cachet.carp.freememory
.
The DataTypeSamplingSchemeMap
defines the configuration of each measure, by specifying the CamsDataTypeMetaData
for each measure and its sampling configuration (see below). The onRegister()
method is called when the package is registered and in this case registers the data classes for JSON serialization (see below). Finally, the create()
method is called when a probe is to be created and returns the right probe based on the measure type.
A note on Permissions - the
CamsDataTypeMetaData
allows the specification of what OS-specific permissions are needed to collect a measure. In the device measures defined above, no permissions are required. But in theSensorSamplingPackage
, thePermission.activityRecognition
is required in order to collect step counts, as shown in thesamplingSchemes
of that package.
Default sampling configurations can be specified as part of samplingSchemes
, like the IntervalSamplingConfiguration
specified for the FREE_MEMORY
measure type above.
There are several built-in sampling configurations available:
PersistentSamplingConfiguration
HistoricSamplingConfiguration
IntervalSamplingConfiguration
PeriodicSamplingConfiguration
But you can write your own sampling configuration tailored to a specific measure and hence how data should be collected in a probe. As an example of how to write a sampling configuration, the IntervalSamplingConfiguration
is shown below:
/// A sampling configuration that allows configuring the time [interval] in
/// between subsequent measurements.
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class IntervalSamplingConfiguration extends PersistentSamplingConfiguration {
/// Sampling interval (i.e., delay between sampling).
Duration interval;
IntervalSamplingConfiguration({required this.interval}) : super();
@override
Function get fromJsonFunction => _$IntervalSamplingConfigurationFromJson;
@override
Map<String, dynamic> toJson() => _$IntervalSamplingConfigurationToJson(this);
factory IntervalSamplingConfiguration.fromJson(Map<String, dynamic> json) =>
FromJsonFactory().fromJson(json) as IntervalSamplingConfiguration;
}
Note that a sampling configuration must extend from one of the sampling configuration types (e.g., SamplingConfiguration
or PersistentSamplingConfiguration
).
Sampling configurations can be part of a study protocol where you can override the sampling configuration of a measure. If you want your own sampling configurations to be serializable to JSON, you need to add JSON serialization according to the carp_serializable
package to them. This entails
- annotated with
@JsonSerializable
- implement the three JSON serializable methods;
fromJsonFunction
,fromJson
,toJson
- register the configuration's
fromJsonFunction
in theFromJsonFactory
, e.g. in theonRegistry()
callback function as shown below:
@override
void onRegister() {
FromJsonFactory()
.register(IntervalSamplingConfiguration(interval: Duration(days: 1)));
}
See the carp_serializable
package for details on serialization.
Next, you should define the package-specific Data
that the package collects.
Here is the example of the collected FreeMemory
data item;
/// Holds information about free memory on the phone.
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class FreeMemory extends Data {
static const dataType = DeviceSamplingPackage.FREE_MEMORY;
/// Amount of free physical memory in bytes.
int? freePhysicalMemory;
/// Amount of free virtual memory in bytes.
int? freeVirtualMemory;
FreeMemory([this.freePhysicalMemory, this.freeVirtualMemory]) : super();
@override
Function get fromJsonFunction => _$FreeMemoryFromJson;
factory FreeMemory.fromJson(Map<String, dynamic> json) =>
FromJsonFactory().fromJson(json) as FreeMemory;
@override
Map<String, dynamic> toJson() => _$FreeMemoryToJson(this);
@override
String toString() =>
'${super.toString()}, physical: $freePhysicalMemory, virtual: $freeVirtualMemory';
}
Note that;
- a data item must always extend
Data
. - must be annotated with the
@JsonSerializable
- must implement the JSON serialization methods;
fromJsonFunction
,fromJson
, andtoJson
A small note on Privacy; it is possible to add privacy protection of collected data to the package. See how to create data privacy for how to do this.
The next step is to implement probes for each measure type. A probe implements how a measure type is collected, collects the data, and returns it as Measurement
.
The abstract Probe
interface defines a probe and how to implement probes. But in order to create your own probes, CAMS has a set of predefined, abstract probes to extend from:
-
MeasurementProbe
- a probe collecting only one measurement with some data and then stops. -
IntervalProbe
- a probe collecting a measurement on a regular basis. -
StreamProbe
- a probe listening on a stream of measurements. -
PeriodicStreamProbe
- a probe listening on a stream of measurements during a sampling window defined by an interval and duration. -
BufferingPeriodicProbe
- a probe that collects data for a period of time and then returns a measurement from this collected data. -
BufferingIntervalStreamProbe
- a probe which buffers data from an underlying stream and on a regular interval returns a measurement based on this collected data. -
BufferingPeriodicStreamProbe
- a probe which buffers data from an underlying stream for a period of time and then returns a measurement from this collected data.
The DeviceProbe
is an example of a simple MeasurementProbe
that collects device information about the phone using the device_info_plus plugin and map this to a DeviceInformation
data and returns a Measurement
with this data.
As shown below, the only thing this probe does is to implement the getMeasurement()
method, which returns the device info mapped to a DeviceInformation
data item.
/// A probe that collects the device info about this device.
class DeviceProbe extends MeasurementProbe {
@override
Future<Measurement?> getMeasurement() async =>
Measurement.fromData(DeviceInformation(
deviceData: DeviceInfo().deviceData,
platform: DeviceInfo().platform,
deviceId: DeviceInfo().deviceID,
deviceName: DeviceInfo().deviceName,
deviceModel: DeviceInfo().deviceModel,
deviceManufacturer: DeviceInfo().deviceManufacturer,
operatingSystem: DeviceInfo().operatingSystemName,
hardware: DeviceInfo().hardware,
));
}
The ScreenProbe
is an example of a StreamProbe
that collects screen activity data by implementing the stream
property, which maps screen events from the screen_state plugin to ScreenEvent
data objects, which again is wrapped in a measurement using the Measurement.fromData
factory.
/// A probe collecting screen events:
/// - SCREEN ON
/// - SCREEN OFF
/// - SCREEN UNLOCK
/// which are stored as a [ScreenEvent].
///
/// This probe is only available on Android.
class ScreenProbe extends StreamProbe {
Screen screen = Screen();
@override
Stream<Measurement>? get stream => (screen.screenStateStream != null)
? screen.screenStateStream!.map((event) =>
Measurement.fromData(ScreenEvent.fromScreenStateEvent(event)))
: null;
}
Now that all package classes have been designed, the final steps are to generate the JSON serialization helper classes (from the carp_serializable
Dart package) and to bundle the package together.
The files used in JSON serialization can be generated using build_runner
, by running the following command in the root of your Flutter project:
dart run build_runner build --delete-conflicting-outputs
This will generate the necessary .g.dart
files.
Finally, all files should be bundled together in a Dart library. For example, the carp_device_package
package looks like this;
/// A library containing a sampling package for collecting information from the device hardware:
/// - device info
/// - battery
/// - screen
/// - free memory
library device;
import 'dart:async';
import 'dart:io' show Platform;
import 'package:carp_serializable/carp_serializable.dart';
import 'package:carp_core/carp_core.dart';
import 'package:carp_mobile_sensing/carp_mobile_sensing.dart';
import 'package:battery_plus/battery_plus.dart' as battery;
import 'package:json_annotation/json_annotation.dart';
import 'package:screen_state/screen_state.dart';
import 'package:system_info2/system_info2.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
part 'device.g.dart';
part 'device_data.dart';
part 'device_package.dart';
part 'device_probes.dart';
To use a sampling package, import it into your app together with the
carp_mobile_sensing
package:
import 'package:carp_core/carp_core.dart';
import 'package:carp_mobile_sensing/carp_mobile_sensing.dart';
import 'package: carp_device_package/carp_device_package.dart';
Before creating a study and running it, register this package in the SamplingPackageRegistry.
SamplingPackageRegistry().register(DeviceSamplingPackage());
A package can also be released as a Dart Package on Pub. We already provide a list of different sampling packages - both using the onboard phone sensors as well as external wearable devices and online services.
If a sampling package handles (i.e., collects data from) an external device (or service), then it should be able to specify what type of device it supports and provide corresponding DeviceManager
and DeviceDescriptor
classes.
For example, in the eSense sampling package, the ESenseSamplingPackage
implements the following two methods:
class ESenseSamplingPackage implements SamplingPackage {
final DeviceManager _deviceManager =
ESenseDeviceManager(ESenseDevice.DEVICE_TYPE);
// other parts of the package omitted
@override
List<Permission> get permissions => [];
@override
String get deviceType => ESenseDevice.DEVICE_TYPE;
@override
DeviceManager get deviceManager => _deviceManager;
}
The ESenseDevice device configuration describes how an eSense device is to be configured:
/// A [DeviceDescriptor] for an eSense device used in a [StudyProtocol].
@JsonSerializable(includeIfNull: false, explicitToJson: true)
class ESenseDevice extends DeviceConfiguration {
/// The type of an eSense device.
static const String DEVICE_TYPE = '${DeviceConfiguration.DEVICE_NAMESPACE}.ESenseDevice';
/// The default role name for an eSense device.
static const String DEFAULT_ROLENAME = 'eSense';
/// The name of the eSense device.
/// Used for connecting to the eSense hardware device over BTLE.
/// eSense devices are typically named `eSense-xxxx`.
String? deviceName;
/// The sampling rate in Hz of getting sensor data from the device.
int samplingRate;
ESenseDevice({
super.roleName = ESenseDevice.DEFAULT_ROLENAME,
super.isOptional = true,
this.deviceName,
this.samplingRate = 10,
});
@override
Function get fromJsonFunction => _$ESenseDeviceFromJson;
factory ESenseDevice.fromJson(Map<String, dynamic> json) =>
FromJsonFactory().fromJson(json) as ESenseDevice;
@override
Map<String, dynamic> toJson() => _$ESenseDeviceToJson(this);
}
Note again that since a device configuration can be part of a study protocol, it needs to support JSON serialization.
A device manager should implement the DeviceManager interface. However, CAMS has a set of predefined device managers which can be extended, including the SmartphoneDeviceManager
, OnlineServiceManager
, HardwareDeviceManager
, and BTLEDeviceManager
.
Since the eSense sensor is a BLE device, the ESenseDeviceManager extends the BTLEDeviceManager class.
/// A [DeviceManager] for the eSense device.
class ESenseDeviceManager extends BTLEDeviceManager<ESenseDevice> {
Timer? _batteryTimer;
StreamSubscription<ESenseEvent>? _batterySubscription;
double? _voltageLevel;
final StreamController<int> _batteryEventController =
StreamController.broadcast();
/// A handle to the [ESenseManager] plugin.
ESenseManager? manager;
@override
List<Permission> get permissions => [
Permission.microphone,
Permission.bluetoothConnect,
Permission.bluetoothScan,
];
@override
String get id => configuration?.deviceName ?? 'eSense-????';
@override
String? get displayName => btleName;
@override
String get btleName => configuration?.deviceName ?? 'eSense-????';
/// Set the name of this device based on the Bluetooth name.
/// This name is used for connecting to the device.
@override
set btleName(String btleName) => configuration?.deviceName = btleName;
/// An estimate of the battery level of the eSense device.
@override
int? get batteryLevel => // details omitted
@override
Stream<int> get batteryEvents => _batteryEventController.stream;
ESenseDeviceManager(
super.type, [
super.configuration,
]);
@override
Future<bool> canConnect() async => (configuration?.deviceName != null &&
configuration!.deviceName!.isNotEmpty);
@override
Future<void> onRequestPermissions() async {}
@override
void onInitialize(ESenseDevice configuration) {
if (configuration.deviceName == null || configuration.deviceName!.isEmpty) {
warning(
'$runtimeType - cannot connect to eSense device, device name is null.');
}
manager = ESenseManager(id);
super.onInitialize(configuration);
}
@override
Future<DeviceStatus> onConnect() async {
// details omitted
return DeviceStatus.connecting;
}
@override
Future<bool> onDisconnect() async => await manager?.disconnect() ?? false;
}
Once the eSense device manager is in place, the eSense probes (button and IMU sensor) can be implemented as stream probes.
/// Collects eSense button pressed events. It generates an [ESenseButton]
/// every time the button is pressed or released.
class ESenseButtonProbe extends _ESenseProbe {
@override
Stream<Measurement>? get stream => (deviceManager.isConnected)
? deviceManager.manager!.eSenseEvents
.where((event) => event.runtimeType == ButtonEventChanged)
.map((event) => Measurement.fromData(
ESenseButton(
deviceName: deviceManager.manager!.deviceName,
pressed: (event as ButtonEventChanged).pressed),
))
.asBroadcastStream()
: null;
}
Both the ESenseDeviceManager
and the ESenseProbe
make use of the ESenseManager
from the esense_flutter
Flutter Plugin.
CAMS comes with a set of built-in and external data managers - see Appendix C for an overview.
It is possible to extend CAMS with support for new data managers that can save or upload data to custom data backends. Support for this is done by implementing 3 interfaces:
A data endpoint is included in the study protocol and specifies what data manager to use for storing or forwarding data, and the configuration of this data manager. Any new data manager should have a corresponding data endpoint that extends from the DataEndPoint
class. For example, the FileDataEndPoint
specifies that data should be saved to a file using the FileDataManager
data manager. This FileDataEndPoint
allows for configuring the data manager by specifying buffer size and whether the file should be zipped or encrypted.
Note that a data endpoint can be used in a study protocol and hence needs to be serializable. Hence, any new data endpoint needs to include support for toJson
and fromJson
methods, as described above.
A data manager implements the functionality for actually storing or forwarding the collected data. Any new data manager should implement the DataManager
interface:
/// The [DataManager] interface is used to upload [Measurement] objects to any
/// data manager that implements this interface.
abstract class DataManager {
/// The deployment using this data manager.
PrimaryDeviceDeployment get deployment;
/// The ID of the study deployment that this manager is handling.
String get studyDeploymentId;
/// The type of this data manager as enumerated in [DataEndPointTypes].
String get type;
/// Initialize the data manager by specifying the study [deployment], the
/// [dataEndPoint], and the stream of [measurements] events to handle.
Future<void> initialize(
DataEndPoint dataEndPoint,
SmartphoneDeployment deployment,
Stream<Measurement> measurements,
);
/// Flush any buffered data and close this data manager.
/// After calling [close] the data manager can no longer be used.
Future<void> close();
/// Stream of data manager events.
Stream<DataManagerEvent> get events;
/// On each measurement collected, the [onMeasurement] handler is called.
///
/// Implementations of this interface should handle how to save
/// or upload the [measurement].
Future<void> onMeasurement(Measurement measurement);
/// When the data stream closes, the [onDone] handler is called.
Future<void> onDone();
/// When an error event is sent on the stream, the [onError] handler is called.
Future<void> onError(Object error);
}
All of these methods have to be implemented. However, the AbstractDataManager
class provides a useful class to start from.
For example, the ConsoleDataManager
provides a very simple example of a data manager that prints a json encoded version of the data to the console.
/// A very simple data manager that just "uploads" the data to the
/// console (i.e., prints it). Used mainly for testing and debugging purposes.
class ConsoleDataManager extends AbstractDataManager {
@override
String get type => DataEndPointTypes.PRINT;
@override
Future<void> onMeasurement(Measurement measurement) async =>
print(jsonEncode(measurement));
}
When creating a new data manager, a corresponding DataManagerFactory
must be provided. This factory knows how to create a data manager of a specific type. The interface looks like this.
/// A factory which can create a [DataManager] based on the `type` of an
/// [DataEndPoint].
abstract class DataManagerFactory {
/// The [DataEndPoint] type.
String get type;
/// Create a [DataManager].
DataManager create();
}
The factory for the ConsoleDataManager
looks like this:
class ConsoleDataManagerFactory implements DataManagerFactory {
@override
String get type => DataEndPointTypes.PRINT;
@override
DataManager create() => ConsoleDataManager();
}
For a data manager to be used in CAMS, its factory must be registered in the DataManagerRegistry
singleton like this:
DataManagerRegistry().register(ConsoleDataManagerFactory());
This should happen at app start-up and before the protocol is loaded and sensing is started.
Data transformation is supported by the DataTransformerSchema
class and can be implemented by implementing the namespace
getter and the onRegister()
callback function.
As an example, the implementation of the Open mHealth transformer schema is shown below:
/// A default [DataTransformerSchema] for Open mHealth (OMH) transformers
class OMHTransformerSchema extends DataTransformerSchema {
@override
String get namespace => NameSpace.OMH;
@override
void onRegister() {}
}
Each transformer schema must be registered in the TransformerSchemaRegistry
(which is a singleton).
Hence, add the following line to your setup up part of the app:
TransformerSchemaRegistry().register(OMHTransformerSchema());
Once the schema is registered, transformers for each data type can be created.
Data transformation is a transformation of one type of data to another type of data. I.e. data transformation is defined by the DataTransformer
typedef.
typedef DataTransformer = Data Function(Data);
For each Data
you want to transform, you need to define a new class that also extends Data
.
For example the following OMHGeopositionDataPoint
class represents a OMH Geoposition
data point.
/// Holds an OMH [Geoposition](https://pub.dartlang.org/documentation/openmhealth_schemas/latest/domain_omh_geoposition/Geoposition-class.html)
/// data point.
class OMHGeopositionDataPoint extends OMHContextDataPoint
implements DataTransformerFactory {
OMHGeopositionDataPoint(DataPoint datapoint) : super(datapoint);
factory OMHGeopositionDataPoint.fromLocationData(Location location) {
var pos = Geoposition(
latitude: PlaneAngleUnitValue(
unit: PlaneAngleUnit.DEGREE_OF_ARC, value: location.latitude),
longitude: PlaneAngleUnitValue(
unit: PlaneAngleUnit.DEGREE_OF_ARC, value: location.longitude),
positioningSystem: PositioningSystem.GPS);
return OMHGeopositionDataPoint(
DataPoint(body: pos, provenance: OMHContextDataPoint.provenance));
}
factory OMHGeopositionDataPoint.fromJson(Map<String, dynamic> json) =>
OMHGeopositionDataPoint(DataPoint.fromJson(json));
static DataTransformer get transformer =>
((data) => OMHGeopositionDataPoint.fromLocationData(data as Location));
}
The most important function to implement is the transformer
, and the make support for JSON serialization (in the example above, this is handled by the superclass OMHContextDataPoint
). The transformer
is a function that can transform one type of Data
to this type of data. The mapping between the two types of data happens in the fromLocationDatum
factory. The toJson
method is needed in order to serialize and store the data. Also, note that the namespace of the DataFormat
of this OMHGeopositionDatum
is OMH
.
Once the mapper Data class is created, it must be added to the schema. This is done by calling;
TransformerSchemaRegistry().lookup(NameSpace.OMH)!
..add(LOCATION, OMHGeopositionDataPoint.transformer);
In the carp_context_package
, this is done in the onRegister()
method in the ContextSamplingPackage
.
Transformation of data can be done "manually" by looking up a specific transformer schema and apply its transformer. For example, the following code transforms a Location
into an OMHGeopositionDatum
.
Location loc = Location()
..longitude = 12.23342
..latitude = 3.34224
..altitude = 124.2134235;
OMHGeopositionDatum geo = TransformerSchemaRegistry().lookup(NameSpace.OMH).transform(loc);
However, a more general and common use of transformers is to specify what data format the DataEndPoint
should use.
This is done via the dataFormat
property, which specified the namespace, like NameSpace.OMH
.
The data endpoint can then be added to the study runtime when started:
await controller.configure(
dataEndPoint: FileDataEndPoint(
bufferSize: 50 * 1000,
dataFormat: NameSpace.OMH,
),
);
It is also possible to specify the data endpoint and its format as part of the study protocol:
SmartphoneStudyProtocol protocol = SmartphoneStudyProtocol(
ownerId: 'user@dtu.dk',
name: 'Track patient movement',
dataEndPoint: FileDataEndPoint(
bufferSize: 50 * 1000,
dataFormat: NameSpace.OMH,
),
);
A special instance of a data transformer schema is the PrivacySchema
, which basically takes a piece of data and privacy protects relevant properties.
CAMS comes with a built-in schema with a transformer namespace specified in PrivacySchema.DEFAULT
.
Hence, in order to add privacy protection, two things has to be implemented;
(i) a transformer function that knows how to transform a Data
object so that its properties are privacy-protected, and
(ii) register this funtion in the TransformerSchemaRegistry
as part of the default PrivacySchema.DEFAULT
schema.
A function that anonymizes a text message datum may look like this:
/// A [TextMessage] anonymizer function. Anonymizes:
/// - address
/// - body
TextMessage textMessageAnoymizer(Data data) {
assert(data is TextMessage);
var msg = data as TextMessage;
if (msg.address != null) {
msg.address = sha1.convert(utf8.encode(msg.address!)).toString();
}
if (msg.body != null) {
msg.body = sha1.convert(utf8.encode(msg.body!)).toString();
}
return msg;
}
This function can be put anywhere in the sampling package, but a typical place to put it is in a separate file (if you have many functions) or in the package dart file (if you only have a few).
Registering of the anonymizer function is done with this statement:
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT)!.add(TEXT_MESSAGE, textMessageAnoymizer);
This is done as part of the onRegister()
callback in the package.
The example above is implemented as part of the carp_communication_package
package.
It is good practice to consider if your data sampling packages needs to supply privacy protecting functions, and if so, add these to the default privacy schema.