|
|
|
@ -1,11 +1,4 @@
|
|
|
|
using System;
|
|
|
|
using System.Collections.ObjectModel;
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
|
|
using System.Collections.ObjectModel;
|
|
|
|
|
|
|
|
using System.Linq;
|
|
|
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
|
|
|
using Microsoft.EntityFrameworkCore.Metadata;
|
|
|
|
|
|
|
|
using Microsoft.VisualBasic;
|
|
|
|
|
|
|
|
using OxyPlot;
|
|
|
|
using OxyPlot;
|
|
|
|
using OxyPlot.Axes;
|
|
|
|
using OxyPlot.Axes;
|
|
|
|
using OxyPlot.Legends;
|
|
|
|
using OxyPlot.Legends;
|
|
|
|
@ -30,9 +23,10 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
Model.TextColor = OxyColors.Black;
|
|
|
|
Model.TextColor = OxyColors.Black;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void SetTankLineGraph(Dictionary<int, ObservableCollection<WaterQualityVO>> collection,
|
|
|
|
public void SetTankLineGraph(
|
|
|
|
|
|
|
|
Dictionary<int, ObservableCollection<WaterQualityVO>> collection,
|
|
|
|
FieldItem? xField, FieldItem? yField,
|
|
|
|
FieldItem? xField, FieldItem? yField,
|
|
|
|
bool isMarker)
|
|
|
|
bool isMarker, bool showLegends)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
Model.Series.Clear();
|
|
|
|
Model.Series.Clear();
|
|
|
|
Model.Axes.Clear();
|
|
|
|
Model.Axes.Clear();
|
|
|
|
@ -57,9 +51,10 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(yAxis);
|
|
|
|
Model.Axes.Add(yAxis);
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var (tankNum, data) in collection.OrderBy(x => x.Key))
|
|
|
|
foreach (var (tankNum, datas) in collection.OrderBy(x => x.Key))
|
|
|
|
{
|
|
|
|
{
|
|
|
|
if (data == null || data.Count == 0) continue;
|
|
|
|
if (datas == null || datas.Count == 0)
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var series = new LineSeries()
|
|
|
|
var series = new LineSeries()
|
|
|
|
{
|
|
|
|
{
|
|
|
|
@ -68,13 +63,14 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
MarkerSize = isMarker ? 3 : 0
|
|
|
|
MarkerSize = isMarker ? 3 : 0
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var r in data.OrderBy(r => r.RecordedTime))
|
|
|
|
foreach (var data in datas.OrderBy(d => d.RecordedTime))
|
|
|
|
{
|
|
|
|
{
|
|
|
|
var y = ResolveTank(r, yField.Name!);
|
|
|
|
var tank = data.Tanks.Find(t => t.Number.Equals(tankNum));
|
|
|
|
|
|
|
|
var y = ResolveTank(tank, yField.Name!);
|
|
|
|
if (!y.HasValue) continue;
|
|
|
|
if (!y.HasValue) continue;
|
|
|
|
|
|
|
|
|
|
|
|
series.Points.Add(new DataPoint(
|
|
|
|
series.Points.Add(new DataPoint(
|
|
|
|
DateTimeAxis.ToDouble(r.RecordedTime),
|
|
|
|
DateTimeAxis.ToDouble(data.RecordedTime), // 여기서 recordedTime 사용
|
|
|
|
y.Value));
|
|
|
|
y.Value));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -87,6 +83,16 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -219,7 +225,8 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void SetBoxPlot(
|
|
|
|
public void SetBoxPlot(
|
|
|
|
List<WaterQualityVO> rows,
|
|
|
|
ReadOnlyObservableCollection<WaterQualityVO> collection,
|
|
|
|
|
|
|
|
List<int>? selectedTankNums,
|
|
|
|
FieldItem xAxisKind, // 시간 or 수조
|
|
|
|
FieldItem xAxisKind, // 시간 or 수조
|
|
|
|
FieldItem valueField, // 값 필드
|
|
|
|
FieldItem valueField, // 값 필드
|
|
|
|
double boxWidth, // 박스 너비
|
|
|
|
double boxWidth, // 박스 너비
|
|
|
|
@ -248,62 +255,77 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(xAxis);
|
|
|
|
Model.Axes.Add(yAxis);
|
|
|
|
Model.Axes.Add(yAxis);
|
|
|
|
|
|
|
|
|
|
|
|
var series = new BoxPlotSeries()
|
|
|
|
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)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
BoxWidth = boxWidth
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
};
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (rows == null || rows.Count == 0) return;
|
|
|
|
// 3) 팔레트
|
|
|
|
|
|
|
|
var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors;
|
|
|
|
|
|
|
|
|
|
|
|
var bucket = timeBucket ?? TimeSpan.FromHours(1);
|
|
|
|
for (int k = 0; k < tankIds.Count; k++)
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
int tankId = tankIds[k];
|
|
|
|
|
|
|
|
|
|
|
|
var col = rows
|
|
|
|
var series = new BoxPlotSeries
|
|
|
|
.OrderBy(r => r.RecordedTime)
|
|
|
|
{
|
|
|
|
.GroupBy(g => ResolveTankOrTime(g, xAxisKind.Name, bucket))
|
|
|
|
Title = $"Tank {tankId}",
|
|
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
BoxWidth = boxWidth,
|
|
|
|
|
|
|
|
Fill = OxyColor.FromAColor(160, colors[k]),
|
|
|
|
|
|
|
|
Stroke = colors[k],
|
|
|
|
|
|
|
|
StrokeThickness = 1
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
foreach(var (key, data) in col.OrderBy(x => x.Key))
|
|
|
|
for (int ti = 0; ti < timeBuckets.Count; ti++)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
// 1) 수조/시간별 값 리스트
|
|
|
|
var bucketTime = timeBuckets[ti];
|
|
|
|
var values = data
|
|
|
|
|
|
|
|
.Select(d => ResolveTank(d, valueField.Name!))
|
|
|
|
// 해당 시간 버킷 + 해당 수조 + 선택 필드 값 모으기
|
|
|
|
.OfType<double>() // object→double
|
|
|
|
var values = collection
|
|
|
|
.Where(v => !double.IsNaN(v) && !double.IsInfinity(v))
|
|
|
|
.Where(w => FloorToBucket(w.RecordedTime, (TimeSpan)timeBucket!) == bucketTime)
|
|
|
|
.OrderBy(v => v)
|
|
|
|
.SelectMany(w => w.Tanks)
|
|
|
|
|
|
|
|
.Where(t => t.Number == tankId)
|
|
|
|
|
|
|
|
.Select(t => ResolveTank(t, valueField.Name!)) // 선택 필드 동적 접근
|
|
|
|
|
|
|
|
.Where(v => v.HasValue)
|
|
|
|
|
|
|
|
.Select(v => v.Value)
|
|
|
|
.ToList();
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
if (values.Count == 0) continue;
|
|
|
|
if (values.Count < 1) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 사분위수/중앙값 계산
|
|
|
|
var item = CreateBoxPlotItem(ti, values);
|
|
|
|
double q1 = Percentile(values, 0.25);
|
|
|
|
if (item != null) series.Items.Add(item);
|
|
|
|
double median = Percentile(values, 0.50);
|
|
|
|
}
|
|
|
|
double q3 = Percentile(values, 0.75);
|
|
|
|
|
|
|
|
double iqr = q3 - q1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 3) 수염(윗/아랫 경계)과 이상치(Tukey 1.5*IQR)
|
|
|
|
if (series.Items.Count > 0)
|
|
|
|
double lowerFence = q1 - 1.5 * iqr;
|
|
|
|
Model.Series.Add(series);
|
|
|
|
double upperFence = q3 + 1.5 * iqr;
|
|
|
|
}
|
|
|
|
var inliers = values.Where(v => v >= lowerFence && v <= upperFence).ToList();
|
|
|
|
|
|
|
|
var outliers = values.Where(v => v < lowerFence || v > upperFence).ToList();
|
|
|
|
|
|
|
|
double lowerWhisker = inliers.Count > 0 ? inliers.First() : values.First();
|
|
|
|
|
|
|
|
double upperWhisker = inliers.Count > 0 ? inliers.Last() : values.Last();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 4) 아이템 추가 (position = X축 인덱스)
|
|
|
|
Model.Legends.Clear();
|
|
|
|
string label = xAxisKind.Name switch
|
|
|
|
Model.IsLegendVisible = true;
|
|
|
|
|
|
|
|
Model.Legends.Add(new Legend
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"수조" or "Tank" => $"수조 {key}",
|
|
|
|
LegendPlacement = LegendPlacement.Outside,
|
|
|
|
"시간" or "RecordedTime" => FormatBucket(FloorToBucket(data.First().RecordedTime, bucket), bucket),
|
|
|
|
LegendPosition = LegendPosition.RightTop,
|
|
|
|
_ => key
|
|
|
|
LegendOrientation = LegendOrientation.Vertical,
|
|
|
|
};
|
|
|
|
LegendTitle = "수조",
|
|
|
|
xAxis.Labels.Add(label);
|
|
|
|
TextColor = OxyColors.Black
|
|
|
|
int position = xAxis.Labels.Count - 1;
|
|
|
|
});
|
|
|
|
var item = new BoxPlotItem(position, lowerWhisker, q1, median, q3, upperWhisker);
|
|
|
|
|
|
|
|
foreach (var o in outliers) item.Outliers.Add(o);
|
|
|
|
|
|
|
|
series.Items.Add(item);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Model.Series.Add(series);
|
|
|
|
|
|
|
|
Model.IsLegendVisible = false;
|
|
|
|
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -343,42 +365,42 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
MarkerFill = OxyColors.DeepSkyBlue
|
|
|
|
MarkerFill = OxyColors.DeepSkyBlue
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var row in rows)
|
|
|
|
//foreach (var row in rows)
|
|
|
|
{
|
|
|
|
//{
|
|
|
|
double x = ResolveTank(row, xAxisField.Name) ?? double.NaN;
|
|
|
|
// double x = ResolveTank(row, xAxisField.Name) ?? double.NaN;
|
|
|
|
double y = ResolveTank(row, yAxisField.Name) ?? double.NaN;
|
|
|
|
// double y = ResolveTank(row, yAxisField.Name) ?? double.NaN;
|
|
|
|
scatterSeries.Points.Add(new ScatterPoint(x, y));
|
|
|
|
// scatterSeries.Points.Add(new ScatterPoint(x, y));
|
|
|
|
}
|
|
|
|
//}
|
|
|
|
|
|
|
|
|
|
|
|
Model.Series.Add(scatterSeries);
|
|
|
|
Model.Series.Add(scatterSeries);
|
|
|
|
|
|
|
|
|
|
|
|
if (showRegression && rows.Count > 1)
|
|
|
|
//if (showRegression && rows.Count > 1)
|
|
|
|
{
|
|
|
|
//{
|
|
|
|
var points = rows.Select(r
|
|
|
|
// var points = rows.Select(r
|
|
|
|
=> new DataPoint(
|
|
|
|
// => new DataPoint(
|
|
|
|
ResolveTank(r, xAxisField.Name) ?? double.NaN,
|
|
|
|
// ResolveTank(r, xAxisField.Name) ?? double.NaN,
|
|
|
|
ResolveTank(r, yAxisField.Name) ?? double.NaN))
|
|
|
|
// ResolveTank(r, yAxisField.Name) ?? double.NaN))
|
|
|
|
.ToList();
|
|
|
|
// .ToList();
|
|
|
|
var regression = LinearRegression(points);
|
|
|
|
// var regression = LinearRegression(points);
|
|
|
|
|
|
|
|
|
|
|
|
if (regression != null)
|
|
|
|
// if (regression != null)
|
|
|
|
{
|
|
|
|
// {
|
|
|
|
var lineSeries = new LineSeries
|
|
|
|
// var lineSeries = new LineSeries
|
|
|
|
{
|
|
|
|
// {
|
|
|
|
Title = "Regression",
|
|
|
|
// Title = "Regression",
|
|
|
|
Color = OxyColors.Red,
|
|
|
|
// Color = OxyColors.Red,
|
|
|
|
StrokeThickness = 2
|
|
|
|
// StrokeThickness = 2
|
|
|
|
};
|
|
|
|
// };
|
|
|
|
|
|
|
|
|
|
|
|
// 최소/최대 구간으로 선 그리기
|
|
|
|
// // 최소/최대 구간으로 선 그리기
|
|
|
|
double minX = points.Min(p => p.X);
|
|
|
|
// double minX = points.Min(p => p.X);
|
|
|
|
double maxX = points.Max(p => p.X);
|
|
|
|
// double maxX = points.Max(p => p.X);
|
|
|
|
lineSeries.Points.Add(new DataPoint(minX, regression.Value.Intercept + regression.Value.Slope * minX));
|
|
|
|
// lineSeries.Points.Add(new DataPoint(minX, regression.Value.Intercept + regression.Value.Slope * minX));
|
|
|
|
lineSeries.Points.Add(new DataPoint(maxX, regression.Value.Intercept + regression.Value.Slope * maxX));
|
|
|
|
// lineSeries.Points.Add(new DataPoint(maxX, regression.Value.Intercept + regression.Value.Slope * maxX));
|
|
|
|
|
|
|
|
|
|
|
|
Model.Series.Add(lineSeries);
|
|
|
|
// Model.Series.Add(lineSeries);
|
|
|
|
}
|
|
|
|
// }
|
|
|
|
}
|
|
|
|
//}
|
|
|
|
|
|
|
|
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -619,6 +641,25 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
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)
|
|
|
|
private DateTime FloorToBucket(DateTime dt, TimeSpan bucket)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
long ticks = bucket.Ticks;
|
|
|
|
long ticks = bucket.Ticks;
|
|
|
|
@ -626,16 +667,15 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
return new DateTime(floored, dt.Kind);
|
|
|
|
return new DateTime(floored, dt.Kind);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private double? ResolveTank(WaterQualityVO vo, string fieldName)
|
|
|
|
private double? ResolveTank(WaterTank tank, string fieldName)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
return fieldName switch
|
|
|
|
return fieldName switch
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"RecordedTime" => DateTimeAxis.ToDouble(vo.RecordedTime),
|
|
|
|
"DOValue" => tank.DOValue,
|
|
|
|
"Tank.DOValue" => vo.Tank.DOValue,
|
|
|
|
"PH" => tank.PH,
|
|
|
|
"Tank.PH" => vo.Tank.PH,
|
|
|
|
"ORP" => tank.ORP,
|
|
|
|
"Tank.ORP" => vo.Tank.ORP,
|
|
|
|
"Temperature" => tank.Temperature,
|
|
|
|
"Tank.Temperature" => vo.Tank.Temperature,
|
|
|
|
"FlowRate" => tank.FlowRate,
|
|
|
|
"Tank.FlowRate" => vo.Tank.FlowRate,
|
|
|
|
|
|
|
|
_ => null
|
|
|
|
_ => null
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -680,13 +720,18 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private string ResolveTankOrTime(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null)
|
|
|
|
private string ResolveTankOrTime(WaterQualityVO data, string xAxisKind, TimeSpan bucket)
|
|
|
|
{
|
|
|
|
{
|
|
|
|
return fieldName switch
|
|
|
|
int? tankId = data.Tanks?.FirstOrDefault().Number; // 분리된 구조이므로 항상 하나만 존재해야 함
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return xAxisKind switch
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"수조" or "Tank.Number" => vo.Tank.Number.ToString(),
|
|
|
|
"수조" or "Tank" => tankId?.ToString() ?? "Unknown",
|
|
|
|
"시간" or "RecordedTime" => FormatBucket(FloorToBucket(vo.RecordedTime, (TimeSpan)bucket), (TimeSpan)bucket),
|
|
|
|
|
|
|
|
_ => ""
|
|
|
|
"시간" or "RecordedTime" =>
|
|
|
|
|
|
|
|
$"{FloorToBucket(data.RecordedTime, bucket):yyyy-MM-dd HH:mm} | Tank {tankId}",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_ => "Unknown"
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|