You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1085 lines
40 KiB
1085 lines
40 KiB
using System.Collections.ObjectModel;
|
|
using Microsoft.VisualBasic;
|
|
using OxyPlot;
|
|
using OxyPlot.Axes;
|
|
using OxyPlot.Legends;
|
|
using OxyPlot.Series;
|
|
using SmartAquaViewer.DataAnalysis;
|
|
using SmartAquaViewer.Model;
|
|
|
|
namespace SmartAquaViewer.ViewModel
|
|
{
|
|
public class GraphControlViewModel
|
|
{
|
|
public PlotModel Model { get; }
|
|
|
|
public GraphControlViewModel()
|
|
{
|
|
Model = new PlotModel();
|
|
InitializeGraph();
|
|
}
|
|
|
|
private void InitializeGraph()
|
|
{
|
|
Model.TextColor = OxyColors.Black;
|
|
}
|
|
|
|
public void SetTankLineGraph(
|
|
ReadOnlyObservableCollection<WaterQualityVO> collection,
|
|
List<int>? selectedTankNums,
|
|
FieldItem? xField, FieldItem? yField,
|
|
bool isMarker, bool showLegends)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
bool xIsTime = string.Equals(xField!.Name, "RecordedTime", StringComparison.OrdinalIgnoreCase);
|
|
|
|
Axis xAxis = xIsTime
|
|
? new DateTimeAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xField.Display,
|
|
StringFormat = "MM-dd\nHH:mm",
|
|
IntervalType = DateTimeIntervalType.Minutes,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
}
|
|
: new LinearAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xField.Display,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = yField!.Display,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
var allTankIds = collection.SelectMany(w => w.Tanks)
|
|
.Select(t => t.Number)
|
|
.Distinct()
|
|
.OrderBy(id => id)
|
|
.ToList();
|
|
var tankIds = (selectedTankNums == null || !selectedTankNums.Any())
|
|
? allTankIds
|
|
: allTankIds.Where(id => selectedTankNums.Contains(id)).ToList();
|
|
|
|
if (tankIds.Count == 0) { Model.InvalidatePlot(true); return; }
|
|
|
|
var orderedCollection = collection.OrderBy(r => r.RecordedTime);
|
|
|
|
foreach (var tankId in tankIds)
|
|
{
|
|
var series = new LineSeries()
|
|
{
|
|
Title = tankId.ToString(),
|
|
MarkerType = isMarker ? MarkerType.Circle : MarkerType.None,
|
|
MarkerSize = isMarker ? 3 : 0
|
|
};
|
|
|
|
var points = new List<DataPoint>();
|
|
foreach (var w in orderedCollection)
|
|
{
|
|
var tank = w.Tanks.FirstOrDefault(t => t.Number == tankId);
|
|
if (tank is null) continue;
|
|
|
|
double? xVal = xIsTime
|
|
? DateTimeAxis.ToDouble(w.RecordedTime)
|
|
: ResolveTank(tank, xField.Name!); // 리플렉션: double? 반환
|
|
|
|
double? yVal = ResolveTank(tank, yField.Name!);
|
|
|
|
// ⚠️ 둘 중 하나라도 없으면 스킵
|
|
if (!xVal.HasValue || !yVal.HasValue) continue;
|
|
|
|
points.Add(new DataPoint(xVal.Value, yVal.Value));
|
|
}
|
|
|
|
foreach (var p in points.OrderBy(p => p.X))
|
|
series.Points.Add(p);
|
|
|
|
if (series.Points.Count > 0)
|
|
{
|
|
var xTracker = xIsTime ? $"시간: {{2:HH:mm}}" : $"{xField.Display}: {{2:0.###}}";
|
|
|
|
// 트래커 포맷: 시간, 수조, 지표, 값
|
|
series.TrackerFormatString =
|
|
$"수조 {tankId}\n{xTracker}\n{yField.Display}: {{4:0.###}}";
|
|
|
|
Model.Series.Add(series);
|
|
}
|
|
}
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegends;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = "수조",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetMultiLineGraph(
|
|
List<WaterQualityVO> collection,
|
|
ObservableCollection<FieldItem> yFields,
|
|
DataType dataType,
|
|
bool showMarker, bool showLegend)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
var xAxis = new DateTimeAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = "시간",
|
|
StringFormat = "HH:mm:ss",
|
|
IntervalType = DateTimeIntervalType.Minutes,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = dataType == DataType.Energy ? "전력 (kW)" : "온실가스 (tCO₂)",
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
foreach (var field in yFields)
|
|
{
|
|
var series = new LineSeries()
|
|
{
|
|
Title = field.Display,
|
|
MarkerType = showMarker ? MarkerType.Circle : MarkerType.None,
|
|
MarkerSize = showMarker ? 3 : 0
|
|
};
|
|
foreach (var r in collection.OrderBy(r => r.RecordedTime))
|
|
{
|
|
double? y = null;
|
|
double? v = ResolveGreenhouseGas(r, field.Name!);
|
|
y = dataType == DataType.Energy ? ResolveEnergyField(r, field.Name!) : v;
|
|
if (!y.HasValue) continue;
|
|
series.Points.Add(new DataPoint(
|
|
DateTimeAxis.ToDouble(r.RecordedTime),
|
|
y.Value));
|
|
}
|
|
if (series.Points.Count > 0)
|
|
{
|
|
// 트래커 포맷: 시간, 수조, 지표, 값
|
|
series.TrackerFormatString =
|
|
$"시간: {{2:HH:mm}}\n{field.Display}: {{4:0.###}}";
|
|
Model.Series.Add(series);
|
|
}
|
|
}
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegend;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = dataType == DataType.Energy ? "전력 소비량" : "온실가스 배출량",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetDefaultLineGraph(
|
|
List<WaterQualityVO> collection,
|
|
MonitorTab selectedTab,
|
|
FieldItem? xField, FieldItem? yField,
|
|
bool isMarker)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
bool xIsTime = string.Equals(xField!.Name, "RecordedTime", StringComparison.OrdinalIgnoreCase);
|
|
|
|
Axis xAxis = xIsTime
|
|
? new DateTimeAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xField.Display,
|
|
StringFormat = "HH:mm:ss",
|
|
IntervalType = DateTimeIntervalType.Minutes,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
}
|
|
: new LinearAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xField.Display,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = yField.Display,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
var series = new LineSeries()
|
|
{
|
|
MarkerType = isMarker ? MarkerType.Circle : MarkerType.None,
|
|
MarkerSize = isMarker ? 3 : 0
|
|
};
|
|
|
|
var points = new List<DataPoint>();
|
|
|
|
foreach (var r in collection.OrderBy(r => r.RecordedTime))
|
|
{
|
|
double? xVal = xIsTime
|
|
? DateTimeAxis.ToDouble(r.RecordedTime)
|
|
: double.NaN;
|
|
if (xVal.HasValue && double.IsNaN(xVal.Value))
|
|
{
|
|
xVal = selectedTab switch
|
|
{
|
|
MonitorTab.Filter => ResolveFilter(r, xField.Name!),
|
|
MonitorTab.Sterilizer => ResolveSterilizer(r, xField.Name!),
|
|
_ => xVal
|
|
};
|
|
}
|
|
|
|
double? y = null;
|
|
if (selectedTab.Equals(MonitorTab.Filter))
|
|
y = ResolveFilter(r, yField.Name!);
|
|
else if (selectedTab.Equals(MonitorTab.Sterilizer))
|
|
y = ResolveSterilizer(r, yField.Name!);
|
|
|
|
if (!y.HasValue) continue;
|
|
|
|
points.Add(new DataPoint(
|
|
xVal!.Value,
|
|
y.Value));
|
|
}
|
|
|
|
foreach (var p in points.OrderBy(p => p.X))
|
|
series.Points.Add(p);
|
|
|
|
if (series.Points.Count > 0)
|
|
{
|
|
var xTracker = xIsTime ? $"시간: {{2:HH:mm}}" : $"{xField.Display}: {{2:0.###}}";
|
|
|
|
// 트래커 포맷: 시간, 수조, 지표, 값
|
|
series.TrackerFormatString =
|
|
$"{xTracker}\n{yField.Display}: {{4:0.###}}";
|
|
}
|
|
|
|
Model.Series.Add(series);
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetBoxPlot(
|
|
ReadOnlyObservableCollection<WaterQualityVO> collection,
|
|
List<int>? selectedTankNums,
|
|
FieldItem xAxisKind, // 시간 or 수조
|
|
FieldItem valueField, // 값 필드
|
|
double boxWidth, // 박스 너비
|
|
TimeSpan? timeBucket = null, // 시간 사용할 때 버킷(기본 1시간)
|
|
int maxCategories = 24 // X 카테고리 최대개수(너무 많을 때 최근 N개만)
|
|
)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
// 축
|
|
var xAxis = new CategoryAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
GapWidth = 0.3,
|
|
IsPanEnabled = false,
|
|
IsZoomEnabled = false
|
|
};
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = valueField.Name,
|
|
MinorGridlineStyle = LineStyle.Dot,
|
|
MajorGridlineStyle = LineStyle.Solid
|
|
};
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
var timeBuckets = collection
|
|
.Select(w => FloorToBucket(w.RecordedTime, (TimeSpan)timeBucket))
|
|
.Distinct()
|
|
.OrderBy(t => t)
|
|
.ToList();
|
|
|
|
// X축 라벨
|
|
xAxis.Labels.AddRange(timeBuckets.Select(t => t.ToString("MM-dd HH:mm")));
|
|
|
|
var allTankIds = collection.SelectMany(w => w.Tanks).Select(t => t.Number).Distinct().OrderBy(id => id).ToList();
|
|
var tankIds = (selectedTankNums == null || !selectedTankNums.Any())
|
|
? allTankIds
|
|
: allTankIds.Where(id => selectedTankNums.Contains(id)).ToList();
|
|
|
|
if (tankIds.Count == 0 || timeBuckets.Count == 0)
|
|
{
|
|
Model.InvalidatePlot(true);
|
|
return;
|
|
}
|
|
|
|
// 3) 팔레트
|
|
var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors;
|
|
|
|
for (int k = 0; k < tankIds.Count; k++)
|
|
{
|
|
int tankId = tankIds[k];
|
|
|
|
var series = new BoxPlotSeries
|
|
{
|
|
Title = $"Tank {tankId}",
|
|
BoxWidth = boxWidth,
|
|
Fill = OxyColor.FromAColor(160, colors[k]),
|
|
Stroke = colors[k],
|
|
StrokeThickness = 1
|
|
};
|
|
|
|
for (int ti = 0; ti < timeBuckets.Count; ti++)
|
|
{
|
|
var bucketTime = timeBuckets[ti];
|
|
|
|
// 해당 시간 버킷 + 해당 수조 + 선택 필드 값 모으기
|
|
var values = collection
|
|
.Where(w => FloorToBucket(w.RecordedTime, (TimeSpan)timeBucket!) == bucketTime)
|
|
.SelectMany(w => w.Tanks)
|
|
.Where(t => t.Number == tankId)
|
|
.Select(t => ResolveTank(t, valueField.Name!)) // 선택 필드 동적 접근
|
|
.Where(v => v.HasValue)
|
|
.Select(v => v.Value)
|
|
.ToList();
|
|
|
|
if (values.Count < 1) continue;
|
|
|
|
var item = CreateBoxPlotItem(ti, values);
|
|
if (item != null) series.Items.Add(item);
|
|
}
|
|
|
|
if (series.Items.Count > 0)
|
|
Model.Series.Add(series);
|
|
}
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = true;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = "수조",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetScatterPlot(
|
|
ReadOnlyObservableCollection<WaterQualityVO> collection,
|
|
FieldItem xAxisField,
|
|
FieldItem yAxisField,
|
|
List<int> selectedTankNums,
|
|
double markerSize = 3,
|
|
bool showRegression = false,
|
|
bool showLegends = true)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
bool xIsTime = string.Equals(xAxisField.Name, "RecordedTime", StringComparison.OrdinalIgnoreCase);
|
|
|
|
Axis xAxis = xIsTime
|
|
? new DateTimeAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xAxisField.Display,
|
|
StringFormat = "MM-dd\nHH:mm"
|
|
}
|
|
: new LinearAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = xAxisField.Display
|
|
};
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = yAxisField.Display,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
var allTankIds = collection.SelectMany(w => w.Tanks)
|
|
.Select(t => t.Number)
|
|
.Distinct()
|
|
.OrderBy(id => id)
|
|
.ToList();
|
|
var tankIds = (selectedTankNums == null || !selectedTankNums.Any())
|
|
? allTankIds
|
|
: allTankIds.Where(id => selectedTankNums.Contains(id)).ToList();
|
|
|
|
if (tankIds.Count == 0) { Model.InvalidatePlot(true); return; }
|
|
|
|
// 색상/마커
|
|
var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors;
|
|
var markerCycle = new[]
|
|
{
|
|
MarkerType.Circle, MarkerType.Square, MarkerType.Triangle, MarkerType.Diamond,
|
|
MarkerType.Plus, MarkerType.Star, MarkerType.Cross
|
|
};
|
|
|
|
int k = 0;
|
|
foreach (var tankId in tankIds)
|
|
{
|
|
var series = new ScatterSeries
|
|
{
|
|
Title = $"Tank {tankId}",
|
|
MarkerType = markerCycle[k % markerCycle.Length],
|
|
MarkerSize = markerSize,
|
|
// 색상 자동 배정에 맡기려면 MarkerFill 설정 생략해도 OK
|
|
MarkerFill = OxyColor.FromAColor(160, colors[k]), // 약간 투명
|
|
};
|
|
|
|
// 포인트 수집 (시간순 정렬 권장)
|
|
var points = new List<ScatterPoint>();
|
|
foreach (var w in collection.OrderBy(r => r.RecordedTime))
|
|
{
|
|
foreach (var t in w.Tanks)
|
|
{
|
|
if (t.Number != tankId) continue;
|
|
|
|
// X
|
|
double? xVal = xIsTime
|
|
? DateTimeAxis.ToDouble(w.RecordedTime)
|
|
: ResolveTank(t, xAxisField.Name!); // 수조 객체의 선택필드(리플렉션)
|
|
|
|
// Y
|
|
double? yVal = ResolveTank(t, yAxisField.Name!);
|
|
|
|
if (xVal.HasValue && yVal.HasValue)
|
|
points.Add(new ScatterPoint(xVal.Value, yVal.Value));
|
|
}
|
|
}
|
|
|
|
// 포인트 반영
|
|
if (points.Count > 0)
|
|
{
|
|
series.Points.AddRange(points);
|
|
series.TrackerFormatString =
|
|
$"수조: {tankId}\nX: {{2:0.###}}\nY: {{4:0.###}}";
|
|
|
|
Model.Series.Add(series);
|
|
|
|
// 옵션: 수조별 선형회귀선
|
|
if (showRegression && !xIsTime && points.Count >= 2)
|
|
{
|
|
var (a, b) = FitLinear(points); // y = a*x + b
|
|
double minX = points.Min(p => p.X);
|
|
double maxX = points.Max(p => p.X);
|
|
|
|
var reg = new LineSeries
|
|
{
|
|
Title = $"Tank {tankId} 회귀",
|
|
StrokeThickness = 2,
|
|
Color = colors[k]
|
|
};
|
|
reg.Points.Add(new DataPoint(minX, a * minX + b));
|
|
reg.Points.Add(new DataPoint(maxX, a * maxX + b));
|
|
Model.Series.Add(reg);
|
|
}
|
|
}
|
|
|
|
k++;
|
|
}
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegends;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = "수조",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegends;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = "수조",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetStepPlot(
|
|
List<WaterQualityVO> rows,
|
|
MonitorTab selectedTab,
|
|
FieldItem xAxisField,
|
|
ObservableCollection<FieldItem> yAxisFields,
|
|
bool showMarker = false, bool showLegends = true
|
|
)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
var xAxis = new CategoryAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = "시간",
|
|
GapWidth = 0.2,
|
|
Angle = 45
|
|
};
|
|
|
|
foreach (var r in rows)
|
|
{
|
|
xAxis.Labels.Add(r.RecordedTime.ToString("HH:mm:ss"));
|
|
}
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = "값"
|
|
});
|
|
|
|
foreach (var field in yAxisFields)
|
|
{
|
|
var series = new StairStepSeries
|
|
{
|
|
Title = field.Display,
|
|
MarkerType = showMarker ? MarkerType.Circle : MarkerType.None,
|
|
MarkerSize = 3
|
|
};
|
|
|
|
int i = 0;
|
|
foreach (var r in rows.OrderBy(r => r.RecordedTime))
|
|
{
|
|
double? y = selectedTab.Equals(MonitorTab.Filter)
|
|
? ResolveFilter(r, field.Name!)
|
|
: ResolveSterilizer(r, field.Name!);
|
|
if (y.HasValue)
|
|
series.Points.Add(new DataPoint(i, y.Value));
|
|
|
|
i++;
|
|
}
|
|
|
|
Model.Series.Add(series);
|
|
}
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetStatusSeriesStopPlot(
|
|
List<WaterQualityVO> collection,
|
|
FieldItem yAxisField,
|
|
ObservableCollection<FieldItem> yAxisFields,
|
|
bool showMarker = false, bool showLegends = true)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
var xAxis = new CategoryAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = "시간",
|
|
GapWidth = 0.2
|
|
};
|
|
|
|
foreach (var r in collection)
|
|
{
|
|
xAxis.Labels.Add(r.RecordedTime.ToString("HH:mm:ss"));
|
|
}
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = "값"
|
|
});
|
|
|
|
//var series = new StairStepSeries
|
|
//{
|
|
// MarkerType = showMarker ? MarkerType.Circle : MarkerType.None,
|
|
//};
|
|
|
|
//int i = 0;
|
|
//foreach (var r in collection.OrderBy(r => r.RecordedTime))
|
|
//{
|
|
// string? rawValue = ResolveStatus(r, yAxisField.Name);
|
|
// if (rawValue != null)
|
|
// {
|
|
// double y = MapDeviceStatus(rawValue);
|
|
// series.Points.Add(new DataPoint(i, y));
|
|
// }
|
|
// else
|
|
// {
|
|
// double? uvPower = ResolveUvPowerPerId(r, yAxisField.Name);
|
|
// if (uvPower.HasValue)
|
|
// {
|
|
// series.Points.Add(new DataPoint(i, uvPower.Value));
|
|
// }
|
|
// }
|
|
// i++;
|
|
//}
|
|
|
|
foreach (var field in yAxisFields)
|
|
{
|
|
var series = new StairStepSeries
|
|
{
|
|
Title = field.Display,
|
|
MarkerType = showMarker ? MarkerType.Circle : MarkerType.None,
|
|
MarkerSize = 3
|
|
};
|
|
|
|
int i = 0;
|
|
foreach (var r in collection.OrderBy(r => r.RecordedTime))
|
|
{
|
|
string? rawValue = ResolveStatus(r, field.Name!);
|
|
if (rawValue != null)
|
|
{
|
|
double y = MapDeviceStatus(rawValue);
|
|
series.Points.Add(new DataPoint(i, y));
|
|
}
|
|
else
|
|
{
|
|
double? uvPower = ResolveUvPowerPerId(r, field.Name!);
|
|
if (uvPower.HasValue)
|
|
{
|
|
series.Points.Add(new DataPoint(i, uvPower.Value));
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
|
|
Model.Series.Add(series);
|
|
}
|
|
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegends;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = "수조",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetStackAreaPlot(List<WaterQualityVO> collection, ObservableCollection<FieldItem> yFields,
|
|
DataType dataType,
|
|
bool showMarker, bool showLegends)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
// X축: 시간
|
|
var xAxis = new DateTimeAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
Title = "시간",
|
|
StringFormat = "HH:mm:ss",
|
|
IntervalType = DateTimeIntervalType.Minutes,
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
// Y축: 전력
|
|
var yAxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Left,
|
|
Title = dataType == DataType.Energy ? "전력 (kW)" : "온실가스 (tCO₂)",
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
MinorGridlineStyle = LineStyle.Dot
|
|
};
|
|
|
|
Model.Axes.Add(xAxis);
|
|
Model.Axes.Add(yAxis);
|
|
|
|
// 시간순 정렬
|
|
var records = collection.OrderBy(r => r.RecordedTime).ToList();
|
|
int n = records.Count;
|
|
|
|
// 누적 버퍼
|
|
double[] prevStack = new double[n];
|
|
|
|
foreach (var field in yFields)
|
|
{
|
|
var area = new AreaSeries
|
|
{
|
|
Title = field.Display,
|
|
StrokeThickness = 1,
|
|
MarkerType = showMarker ? MarkerType.Circle : MarkerType.None,
|
|
};
|
|
|
|
var upper = new List<DataPoint>();
|
|
var lower = new List<DataPoint>();
|
|
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
double? y = dataType == DataType.Energy ?
|
|
ResolveEnergyField(records[i], field.Name!) :
|
|
ResolveGreenhouseGas(records[i], field.Name!);
|
|
double value = y ?? 0;
|
|
|
|
double x = DateTimeAxis.ToDouble(records[i].RecordedTime);
|
|
|
|
// 현재 값 누적
|
|
double bottom = prevStack[i];
|
|
double top = bottom + value;
|
|
|
|
lower.Add(new DataPoint(x, bottom));
|
|
upper.Add(new DataPoint(x, top));
|
|
|
|
prevStack[i] = top;
|
|
}
|
|
|
|
area.Points.AddRange(upper); // 위쪽 경계
|
|
area.Points2.AddRange(lower); // 아래쪽 경계
|
|
Model.Series.Add(area);
|
|
}
|
|
|
|
// 레전드
|
|
Model.Legends.Clear();
|
|
Model.IsLegendVisible = showLegends;
|
|
Model.Legends.Add(new Legend
|
|
{
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
LegendPosition = LegendPosition.RightTop,
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
LegendTitle = dataType == DataType.Energy ? "전력 소비량" : "온실가스 배출량",
|
|
TextColor = OxyColors.Black
|
|
});
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
public void SetPieChart(List<WaterQualityVO> collection, ObservableCollection<FieldItem> fields,
|
|
DataType dataType,
|
|
bool useAverage = false, bool donut = true,
|
|
double minLabelPercent = 0.03)
|
|
{
|
|
Model.Series.Clear();
|
|
Model.Axes.Clear();
|
|
|
|
if (collection == null || collection.Count == 0)
|
|
{
|
|
Model.InvalidatePlot(true);
|
|
return;
|
|
}
|
|
|
|
if (fields.Count == 0)
|
|
{
|
|
Model.InvalidatePlot(true);
|
|
return;
|
|
}
|
|
|
|
var agg = new List<(string name, double value)>();
|
|
foreach (var f in fields)
|
|
{
|
|
var values = collection.Select(r => (dataType == DataType.Energy ? ResolveEnergyField(r, f.Name!) : ResolveGreenhouseGas(r, f.Name!)) ?? 0.0);
|
|
double v = (useAverage ? (values.Any() ? values.Average() : 0.0) : values.Sum());
|
|
agg.Add((f.Display ?? f.Name!, v));
|
|
}
|
|
|
|
List<(string name, double value)> finalList;
|
|
finalList = agg;
|
|
|
|
double total = Math.Max(1e-9, finalList.Sum(x => x.value));
|
|
|
|
var ps = new PieSeries
|
|
{
|
|
AngleSpan = 360,
|
|
StartAngle = 0,
|
|
StrokeThickness = 0.5,
|
|
InsideLabelFormat = "{1}\n {0:F2}",
|
|
InsideLabelPosition = 0.8,
|
|
OutsideLabelFormat = null // 라벨은 내부만
|
|
};
|
|
if (donut) ps.InnerDiameter = 0.6; // 도넛 모드
|
|
|
|
foreach (var (name, value) in finalList)
|
|
{
|
|
var pct = value / total;
|
|
var label = (pct >= minLabelPercent) ? $"{name} ({pct:P0})" : ""; // 작은 조각 라벨 숨김
|
|
ps.Slices.Add(new PieSlice(name, value) { });
|
|
}
|
|
|
|
Model.Title = $"설비별 {(useAverage ? "평균" : "합계")} 소비 비중";
|
|
Model.Series.Add(ps);
|
|
|
|
Model.InvalidatePlot(true);
|
|
}
|
|
|
|
private BoxPlotItem CreateBoxPlotItem(int xIndex, List<double> values)
|
|
{
|
|
values.Sort();
|
|
int n = values.Count;
|
|
if (n == 0) return null;
|
|
|
|
double q1 = values[(int)(0.25 * (n - 1))];
|
|
double q3 = values[(int)(0.75 * (n - 1))];
|
|
double median = values[(int)(0.5 * (n - 1))];
|
|
double iqr = q3 - q1;
|
|
double lower = values.Where(v => v >= q1 - 1.5 * iqr).DefaultIfEmpty(q1).Min();
|
|
double upper = values.Where(v => v <= q3 + 1.5 * iqr).DefaultIfEmpty(q3).Max();
|
|
|
|
return new BoxPlotItem(xIndex, lower, q1, median, q3, upper)
|
|
{
|
|
Outliers = values.Where(v => v < lower || v > upper).ToList()
|
|
};
|
|
}
|
|
|
|
private DateTime FloorToBucket(DateTime dt, TimeSpan bucket)
|
|
{
|
|
long ticks = bucket.Ticks;
|
|
long floored = dt.Ticks - (dt.Ticks % ticks);
|
|
return new DateTime(floored, dt.Kind);
|
|
}
|
|
|
|
private double? ResolveTank(WaterTank tank, string fieldName)
|
|
{
|
|
return fieldName switch
|
|
{
|
|
"DOValue" => tank.DOValue,
|
|
"PH" => tank.PH,
|
|
"ORP" => tank.ORP,
|
|
"Temperature" => tank.Temperature,
|
|
"FlowRate" => tank.FlowRate,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private double? ResolveFilter(WaterQualityVO vo, string fieldName)
|
|
{
|
|
return fieldName switch
|
|
{
|
|
"Filtering.SumpPH" => vo.Filtering.SumpPH,
|
|
"Filtering.SumpORP" => vo.Filtering.SumpORP,
|
|
"Filtering.SumpWaterLevel" => vo.Filtering.SumpWaterLevel,
|
|
"Filtering.SumpFlowRate" => vo.Filtering.SumpFlowRate,
|
|
"Filtering.SumpTemperature" => vo.Filtering.SumpTemperature,
|
|
"Filtering.FlowRate" => vo.Filtering.FlowRate,
|
|
"Filtering.HeatPumpTemperature" => vo.Filtering.HeatPumpTemperature,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private double? ResolveSterilizer(WaterQualityVO vo, string fieldName)
|
|
{
|
|
return fieldName switch
|
|
{
|
|
"Sterilizing.OzoneDissolverPressure" => vo.Sterilizing.OzoneDissolverPressure,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private string? ResolveStatus(WaterQualityVO vo, string field)
|
|
{
|
|
return field switch
|
|
{
|
|
"Filtering.SandFilterPower" => vo.Filtering.SandFilterPower.ToString(),
|
|
"Filtering.CirculationPumpPower" => vo.Filtering.CirculationPumpPower.ToString(),
|
|
"Filtering.InverterControllerStatus" => vo.Filtering.InverterControllerStatus,
|
|
"Filtering.HeatPumpPower" => vo.Filtering.HeatPumpPower.ToString(),
|
|
"Filtering.AirBlowerPower" => vo.Filtering.AirBlowerPower.ToString(),
|
|
"Sterilizing.OzoneGeneratorPower" => vo.Sterilizing.OzoneGeneratorPower.ToString(),
|
|
"Sterilizing.OzoneDissolverPower" => vo.Sterilizing.OzoneDissolverPower.ToString(),
|
|
"Sterilizing.ExcessOzoneDestroyerPower" => vo.Sterilizing.ExcessOzoneDestroyerPower.ToString(),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private string ResolveTankOrTime(WaterQualityVO data, string xAxisKind, TimeSpan bucket)
|
|
{
|
|
int? tankId = data.Tanks?.FirstOrDefault().Number; // 분리된 구조이므로 항상 하나만 존재해야 함
|
|
|
|
return xAxisKind switch
|
|
{
|
|
"수조" or "Tank" => tankId?.ToString() ?? "Unknown",
|
|
|
|
"시간" or "RecordedTime" =>
|
|
$"{FloorToBucket(data.RecordedTime, bucket):yyyy-MM-dd HH:mm} | Tank {tankId}",
|
|
|
|
_ => "Unknown"
|
|
};
|
|
}
|
|
|
|
private double? ResolveEnergyField(WaterQualityVO vo, string fieldName)
|
|
{
|
|
return fieldName switch
|
|
{
|
|
"Filtering.SandFilterEnergy" => vo.Filtering.SandFilterEnergy,
|
|
"Filtering.CirculationPumpEnergy" => vo.Filtering.CirculationPumpEnergy,
|
|
"Filtering.HeatPumpEnergy" => vo.Filtering.HeatPumpEnergy,
|
|
"Filtering.AirBlowerEnergy" => vo.Filtering.AirBlowerEnergy,
|
|
"Sterilizing.OzoneGeneratorEnergy" => vo.Sterilizing.OzoneGeneratorEnergy,
|
|
"Sterilizing.UVSterilizerEnergy" => vo.Sterilizing.UVSterilizerEnergy,
|
|
"Sterilizing.OzoneDissolverEnergy" => vo.Sterilizing.OzoneDissolverEnergy,
|
|
"Sterilizing.ExcessOzoneDestroyerEnergy" => vo.Sterilizing.ExcessOzoneDestroyerEnergy,
|
|
"TotalEnergy" => vo.TotalEnergy,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private double? ResolveGreenhouseGas(WaterQualityVO vo, string fieldName)
|
|
{
|
|
return fieldName switch
|
|
{
|
|
"Filtering.SandFilterGreenhouseGas" => vo.Filtering.SandFilterGreenhouseGas,
|
|
"Filtering.CirculationPumpGreenhouseGas" => vo.Filtering.CirculationPumpGreenhouseGas,
|
|
"Filtering.HeatPumpGreenhouseGas" => vo.Filtering.HeatPumpGreenhouseGas,
|
|
"Filtering.AirBlowerGreenhouseGas" => vo.Filtering.AirBlowerGreenhouseGas,
|
|
"Sterilizing.OzoneGeneratorGreenhouseGas" => vo.Sterilizing.OzoneGeneratorGreenhouseGas,
|
|
"Sterilizing.UVSterilizerGreenhouseGas" => vo.Sterilizing.UVSterilizerGreenhouseGas,
|
|
"Sterilizing.OzoneDissolverGreenhouseGas" => vo.Sterilizing.OzoneDissolverGreenhouseGas,
|
|
"Sterilizing.ExcessOzoneDestroyerGreenhouseGas" => vo.Sterilizing.ExcessOzoneDestroyerGreenhouseGas,
|
|
"TotalGreenhouseGas" => vo.TotalGreenhouseGas,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private double? ResolveUvPowerPerId(WaterQualityVO r, string fieldName)
|
|
{
|
|
// 케이스 A
|
|
if (fieldName.StartsWith("Sterilizing.UVSterilizerPower[id="))
|
|
{
|
|
var id = fieldName
|
|
.Replace("Sterilizing.UVSterilizerPower[id=", "")
|
|
.TrimEnd(']');
|
|
|
|
if (r?.Sterilizing?.UVSterilizerId == id)
|
|
return MapStatus(r.Sterilizing.UVSterilizerPower);
|
|
|
|
// 해당 시점에 이 ID의 레코드가 없으면 null (직전값 유지 로직에서 커버)
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static double MapStatus(object? v)
|
|
{
|
|
return v switch
|
|
{
|
|
bool b => b ? 1 : 0,
|
|
int i => i, // 이미 0/1/2로 들어온 경우
|
|
string s => s.Equals("True", StringComparison.OrdinalIgnoreCase) ? 1 :
|
|
s.Equals("False", StringComparison.OrdinalIgnoreCase) ? 0 :
|
|
s.Equals("Normal", StringComparison.OrdinalIgnoreCase) ? 1 :
|
|
s.Equals("Error", StringComparison.OrdinalIgnoreCase) ? 2 : -1,
|
|
_ => -1
|
|
};
|
|
}
|
|
|
|
|
|
private static string FormatBucket(DateTime t, TimeSpan bucket)
|
|
{
|
|
if (bucket >= TimeSpan.FromDays(7)) return t.ToString("yyyy-MM-dd");
|
|
if (bucket >= TimeSpan.FromDays(1)) return t.ToString("MM-dd");
|
|
return t.ToString("HH:00");
|
|
}
|
|
|
|
private double Percentile(IList<double> values, double p)
|
|
{
|
|
if (values == null || values.Count == 0) return double.NaN;
|
|
if (values.Count == 1) return (double)values[0];
|
|
|
|
double rank = p * (values.Count - 1);
|
|
int i = (int)Math.Floor(rank);
|
|
double frac = rank - i;
|
|
|
|
if (i >= values.Count - 1) return (double)values.Last();
|
|
return (double)(values[i] + (values[i + 1] - values[i]) * frac);
|
|
}
|
|
|
|
// 단순 선형회귀 계산
|
|
private (double Slope, double Intercept)? LinearRegression(List<DataPoint> points)
|
|
{
|
|
int n = points.Count;
|
|
if (n < 2) return null;
|
|
|
|
double avgX = points.Average(p => p.X);
|
|
double avgY = points.Average(p => p.Y);
|
|
|
|
double numerator = points.Sum(p => (p.X - avgX) * (p.Y - avgY));
|
|
double denominator = points.Sum(p => Math.Pow(p.X - avgX, 2));
|
|
|
|
if (denominator == 0) return null;
|
|
|
|
double slope = numerator / denominator;
|
|
double intercept = avgY - slope * avgX;
|
|
|
|
return (slope, intercept);
|
|
}
|
|
|
|
private (double a, double b) FitLinear(IEnumerable<ScatterPoint> pts)
|
|
{
|
|
int n = 0;
|
|
double sumX = 0, sumY = 0, sumXX = 0, sumXY = 0;
|
|
foreach (var p in pts) { n++; sumX += p.X; sumY += p.Y; sumXX += p.X * p.X; sumXY += p.X * p.Y; }
|
|
double denom = n * sumXX - sumX * sumX;
|
|
if (n < 2 || Math.Abs(denom) < 1e-12) return (0, pts.First().Y); // 수직/특이 케이스
|
|
double a = (n * sumXY - sumX * sumY) / denom;
|
|
double b = (sumY - a * sumX) / n;
|
|
return (a, b);
|
|
}
|
|
|
|
private double MapDeviceStatus(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
"True" => 1, // 전원 ON
|
|
"False" => 0, // 전원 OFF
|
|
"Normal" => 1, // 정상
|
|
"Error" => 2, // 오류
|
|
_ => -1 // 알 수 없음
|
|
};
|
|
}
|
|
|
|
private class KeyComparer : IComparer<string>
|
|
{
|
|
public int Compare(string? a, string? b)
|
|
{
|
|
if (a == null || b == null) return Comparer<string>.Default.Compare(a, b);
|
|
if (a.StartsWith("수조 ") && b.StartsWith("수조 "))
|
|
{
|
|
if (int.TryParse(a.AsSpan(3), out var ai) && int.TryParse(b.AsSpan(3), out var bi))
|
|
return ai.CompareTo(bi);
|
|
}
|
|
return string.Compare(a, b, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
}
|
|
}
|