| name | Create Server Simulation Service |
| description | Create a C# server-side simulation service following the Plugin/IEventAdapter pattern with configuration, pulse updates, and entity management. Use when creating new simulations, scenario generators, or test data providers for FAAD HMI server. |
| allowed-tools | Read, Write, Edit, Grep, Glob |
Create Server Simulation Service
This skill scaffolds a complete server-side simulation service following Phoenix/FAAD server patterns.
When to Use
- Creating new simulation scenarios for testing
- Building test data generators
- Creating scenario runners with realistic behavior
- Adding configurable simulations to canary/testing projects
Prerequisites
- Understand the entity model to simulate (FaadTrack, Platform, etc.)
- Know the desired configuration parameters
- Decide on simulation behavior (movement patterns, state changes, etc.)
CRITICAL Server Concepts
Reference: SERVER.md
Server uses Data-Oriented Design:
- ❌ NO traditional OOP with entity instances
- ✅ YES dictionary-based
IUpdate objects
- ✅ YES builder pattern for entity creation
- ✅ YES pulse-driven updates (external timing control)
Property Patterns:
ValueProperty: { "Value": x } - Single values (enums, types)
CommandedProperty: { "Commanded": x, "Actual": y } - Dual state
RangedCommandedProperty: Adds "Min" and "Max" fields
Process
Step 1: Create Configuration Class
Location: server/com.faad.testing.canary/sims/configuration/{SimName}Configuration.cs
using System;
namespace com.faad.testing.canary.sims.configuration;
public class {SimName}Configuration
{
public int NumberOfEntities { get; set; } = 5;
public bool EnableFeature { get; set; } = true;
}
Configuration Best Practices:
- Always provide sensible defaults
- Use descriptive XML comments
- Keep properties simple (primitives, enums)
- Use bool for feature flags
- Use int for counts/limits
- Use double for physical values
Step 2: Create Simulation Class Structure
Location: server/com.faad.testing.canary/sims/{SimName}Sim.cs
using System.Collections.Concurrent;
using System.ComponentModel.Composition;
using System.Dynamic;
using quicktype;
using esp.extras.common.plugin;
using esp.api.infrastructure.plugin;
using esp.api.infrastructure.model;
using com.faad.testing.canary.sims.configuration;
namespace com.faad.testing.canary.sims
{
[Export("faad.{SimName}Sim", typeof(IPluginMeta))]
public class {SimName}Sim : Plugin, IEventAdapter
{
public {SimName}Configuration? configuration;
private const int MAX_ENTITIES_ADDED_PER_PULSE = 500;
private const int MAX_ENTITIES_UPDATED_PER_PULSE = 1000;
private int _lastAddedIndex = 0;
private _lastUpdatedIndex = ;
ConcurrentDictionary<, EntityData> _entities = ConcurrentDictionary<, EntityData>();
EntityData[] _entityArray = Array.Empty<EntityData>();
DateTime _lastPulse = DateTime.MinValue;
{
Id { ; ; }
CurrentLatitude { ; ; }
CurrentLongitude { ; ; }
DateTime LastUpdateTime { ; ; }
}
{
Console.WriteLine();
(configuration == )
{
Console.WriteLine();
configuration = {SimName}Configuration();
}
_lastPulse = DateTime.Now;
_entities.Clear();
_lastAddedIndex = ;
_lastUpdatedIndex = ;
( i = ; i < configuration.NumberOfEntities; i++)
{
entityId = GenerateEntityId(i);
entityData = GenerateNewEntity(entityId, i);
_entities[entityId] = entityData;
}
_entityArray = _entities.Values.ToArray();
Console.WriteLine();
AddEntityBatchToRepository();
}
{
Console.WriteLine();
_entities.Clear();
}
{
now = DateTime.Now;
(_lastAddedIndex < _entityArray.Length)
{
AddEntityBatchToRepository();
}
addedCount = Math.Min(_lastAddedIndex, _entityArray.Length);
(addedCount == )
{
_lastPulse = now;
;
}
entitiesToUpdate = Math.Min(MAX_ENTITIES_UPDATED_PER_PULSE, addedCount);
updates = List<IUpdate>();
( i = ; i < entitiesToUpdate; i++)
{
entityIndex = (_lastUpdatedIndex + i) % addedCount;
entityData = _entityArray[entityIndex];
entityDeltaTime = now - entityData.LastUpdateTime;
update = UpdateEntity(entityData, entityDeltaTime);
(update != )
{
updates.Add(update);
entityData.LastUpdateTime = now;
_entityArray[entityIndex] = entityData;
}
}
_lastUpdatedIndex = (_lastUpdatedIndex + entitiesToUpdate) % addedCount;
(updates.Count > )
{
updateArray = updates.ToArray();
RepositoryService?.addOrUpdateObjects(updateArray);
updates.Clear();
}
_lastPulse = now;
}
{
Console.WriteLine();
(config jsonConfig)
{
configuration = System.Text.Json.JsonSerializer.Deserialize<{SimName}Configuration>(jsonConfig);
}
{
configuration = config {SimName}Configuration;
}
Console.WriteLine();
}
{
({SimName}Configuration);
}
{
(_lastAddedIndex >= _entityArray.Length) ;
remainingEntities = _entityArray.Length - _lastAddedIndex;
entitiesToAdd = Math.Min(MAX_ENTITIES_ADDED_PER_PULSE, remainingEntities);
(entitiesToAdd <= ) ;
updateList = List<IUpdate>();
( i = ; i < entitiesToAdd; i++)
{
entityData = _entityArray[_lastAddedIndex + i];
update = CreateEntityUpdate(entityData, isInitial: );
updateList.Add(update);
}
(updateList.Count > )
{
updateArray = updateList.ToArray();
RepositoryService?.addOrUpdateObjects(updateArray);
updateList.Clear();
_lastAddedIndex += entitiesToAdd;
Console.WriteLine();
}
}
{
EntityData
{
Id = id,
CurrentLatitude = ,
CurrentLongitude = ,
LastUpdateTime = DateTime.Now
};
}
{
update = esp.extras.infrastructure.model.Update
{
Id = entityData.Id,
ClassName = FaadTrack.classData,
Type = (FaadTrack),
};
currentLat = ()entityData.CurrentLatitude;
currentLon = ()entityData.CurrentLongitude;
platformTypeUpdate = ExpandoObject() IDictionary<, ?>;
platformTypeUpdate[] = ;
update.UpdateProperties[] = platformTypeUpdate;
latUpdate = ExpandoObject() IDictionary<, ?>;
latUpdate[] = currentLat;
latUpdate[] = currentLat;
update.UpdateProperties[] = latUpdate;
lonUpdate = ExpandoObject() IDictionary<, ?>;
lonUpdate[] = currentLon;
lonUpdate[] = currentLon;
update.UpdateProperties[] = lonUpdate;
headingUpdate = ExpandoObject() IDictionary<, ?>;
headingUpdate[] = m;
headingUpdate[] = m;
headingUpdate[] = m;
headingUpdate[] = m;
update.UpdateProperties[] = headingUpdate;
update;
}
IUpdate? UpdateEntity(EntityData entityData, TimeSpan deltaTime)
{
CreateEntityUpdate(entityData, isInitial: );
}
{
;
}
}
}
Step 3: Implement Simulation Logic
Movement/Behavior Patterns:
private IUpdate? UpdateEntity(EntityData entity, TimeSpan deltaTime)
{
double distanceToTarget = CalculateDistance(
entity.CurrentLatitude, entity.CurrentLongitude,
entity.TargetLatitude, entity.TargetLongitude);
if (distanceToTarget < ARRIVAL_THRESHOLD)
{
var newTarget = GenerateNewTarget();
entity.TargetLatitude = newTarget.Item1;
entity.TargetLongitude = newTarget.Item2;
}
var bearing = CalculateBearing();
var newPosition = CalculateNewPosition();
entity.CurrentLatitude = newPosition.Latitude;
entity.CurrentLongitude = newPosition.Longitude;
return CreateEntityUpdate(entity);
}
private IUpdate? UpdateEntity(EntityData entity, TimeSpan deltaTime)
{
entity.Heading += (random.NextDouble() - 0.5) * 10;
entity.Heading = (entity.Heading + 360) % 360;
double distanceMeters = entity.SpeedKts * 0.514444 * deltaTime.TotalSeconds;
var newPos = CalculateNewPosition();
entity.CurrentLatitude = newPos.Latitude;
entity.CurrentLongitude = newPos.Longitude;
return CreateEntityUpdate(entity);
}
private IUpdate? UpdateEntity(EntityData entity, TimeSpan deltaTime)
{
switch (entity.State)
{
case EntityState.Idle:
if (ShouldActivate(entity))
{
entity.State = EntityState.Active;
}
;
EntityState.Active:
UpdateActiveEntity(entity, deltaTime);
;
EntityState.Returning:
;
}
CreateEntityUpdate(entity);
}
Step 4: Add Geospatial Utilities (If Needed)
#region Geospatial Math Utilities
private const double EARTH_RADIUS_MILES = 3958.8;
private double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
{
lat1 = ToRadians(lat1);
lon1 = ToRadians(lon1);
lat2 = ToRadians(lat2);
lon2 = ToRadians(lon2);
double dLat = lat2 - lat1;
double dLon = lon2 - lon1;
double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(lat1) * Math.Cos(lat2) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return EARTH_RADIUS_MILES * c;
}
private double CalculateBearing(double lat1, double lon1, double lat2, double lon2)
{
lat1 = ToRadians(lat1);
lon1 = ToRadians(lon1);
lat2 = ToRadians(lat2);
lon2 = ToRadians(lon2);
double dLon = lon2 - lon1;
double y = Math.Sin(dLon) * Math.Cos(lat2);
double x = Math.Cos(lat1) * Math.Sin(lat2) -
Math.Sin(lat1) * Math.Cos(lat2) * Math.Cos(dLon);
double bearing = Math.Atan2(y, x);
return (bearing + 2 * Math.PI) % (2 * Math.PI);
}
private (double Latitude, Longitude) CalculateNewPosition(
lat, lon, bearing, distance)
{
lat = ToRadians(lat);
lon = ToRadians(lon);
angularDistance = distance / EARTH_RADIUS_MILES;
newLat = Math.Asin(Math.Sin(lat) * Math.Cos(angularDistance) +
Math.Cos(lat) * Math.Sin(angularDistance) * Math.Cos(bearing));
newLon = lon + Math.Atan2(Math.Sin(bearing) * Math.Sin(angularDistance) * Math.Cos(lat),
Math.Cos(angularDistance) - Math.Sin(lat) * Math.Sin(newLat));
(ToDegrees(newLat), ToDegrees(newLon));
}
=> degrees * Math.PI / ;
=> radians * / Math.PI;
Step 5: Verify Build
dotnet build server/com.faad.testing.canary
./tools/build-helpers/count-server-errors.sh
./tools/build-helpers/show-server-errors.sh 10
Critical Patterns
Property Update Pattern
ALWAYS use ExpandoObject as IDictionary:
var propUpdate = new ExpandoObject() as IDictionary<string, object?>;
propUpdate["Actual"] = value;
update.UpdateProperties["propertyName"] = propUpdate;
var dict = new Dictionary<string, object>();
Batch Performance Pattern
Add/update in batches to avoid overwhelming repository:
private const int MAX_ENTITIES_ADDED_PER_PULSE = 500;
for (int i = 0; i < Math.Min(remaining, MAX_ENTITIES_ADDED_PER_PULSE); i++) { ... }
for (int i = 0; i < _entities.Count; i++) { ... }
Thread Safety Pattern
Always pass arrays to RepositoryService:
var updateArray = updates.ToArray();
RepositoryService?.addOrUpdateObjects(updateArray);
updates.Clear();
RepositoryService?.addOrUpdateObjects(updates);
Value Capture Pattern
Capture values before creating ExpandoObject:
decimal currentLat = (decimal)entityData.CurrentLatitude;
var latUpdate = new ExpandoObject() as IDictionary<string, object?>;
latUpdate["Actual"] = currentLat;
latUpdate["Actual"] = (decimal)entityData.CurrentLatitude;
Common Pitfalls
Reference: SERVER.md
- ❌ Don't create entity instances directly - Use IUpdate pattern
- ❌ Don't access dictionary keys without TryGetValue
- ❌ Don't use regular Dictionary for property updates - Use ExpandoObject as IDictionary
- ❌ Don't send List to RepositoryService - Convert to array first
- ❌ Don't add all entities in one pulse - Batch them
- ❌ Don't capture entity references in closures - Capture values
- ❌ Don't forget decimal casting for lat/lon/alt values
- ❌ Don't mix up property patterns (Value vs Commanded vs RangedCommanded)
Real-World Example
Reference: server/com.faad.testing.canary/sims/CrowdedAirspaceSim.cs
Study this example for:
- Batched entity addition
- Waypoint-based movement
- Realistic speed/altitude generation
- Geospatial calculations
- Configuration pattern
- Performance optimizations
File Locations
- Simulation:
server/com.faad.testing.canary/sims/{SimName}Sim.cs
- Configuration:
server/com.faad.testing.canary/sims/configuration/{SimName}Configuration.cs
Testing Your Simulation
- Build server:
dotnet build server/com.faad.testing.canary
- Configure in scenario JSON (see
server/com.faad.runner/configuration/scenarios/)
- Run HMI server and client
- Verify entities appear in UI
- Check console output for batch progress
Ask User If Unclear
- What entity type to simulate? (FaadTrack, Platform, etc.)
- What configuration parameters are needed?
- What behavior pattern? (movement, state machine, random, etc.)
- How many entities should it support?
- Should it be geospatial or abstract?
- What are the realistic value ranges?