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.
SmartAquaViewer/SmartAquaViewer/ViewModel/GraphControlViewModel.cs

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);
}
}
}
}