|
|
|
|
@ -4,8 +4,11 @@ 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.Axes;
|
|
|
|
|
using OxyPlot.Legends;
|
|
|
|
|
using OxyPlot.Series;
|
|
|
|
|
using SmartAquaViewer.DataAnalysis;
|
|
|
|
|
using static SmartAquaViewer.Model.Enums;
|
|
|
|
|
@ -99,7 +102,6 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
|
var xAxis = new DateTimeAxis
|
|
|
|
|
{
|
|
|
|
|
Position = AxisPosition.Bottom,
|
|
|
|
|
Title = "시간",
|
|
|
|
|
StringFormat = "HH:mm:ss",
|
|
|
|
|
IntervalType = DateTimeIntervalType.Minutes,
|
|
|
|
|
MajorGridlineStyle = LineStyle.Solid,
|
|
|
|
|
@ -124,25 +126,23 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
|
|
|
|
|
|
foreach (var r in collection.OrderBy(r => r.RecordedTime))
|
|
|
|
|
{
|
|
|
|
|
double? y = null;
|
|
|
|
|
if (selectedTab.Equals(MonitorTab.Filter))
|
|
|
|
|
{
|
|
|
|
|
var y = ResolveFilterY(r, yField.Name!);
|
|
|
|
|
if (!y.HasValue) continue;
|
|
|
|
|
y = ResolveFilterY(r, yField.Name!);
|
|
|
|
|
else if (selectedTab.Equals(MonitorTab.Sterilizer))
|
|
|
|
|
y = ResolveSterilizerY(r, yField.Name!);
|
|
|
|
|
|
|
|
|
|
series.Points.Add(new DataPoint(
|
|
|
|
|
DateTimeAxis.ToDouble(r.RecordedTime),
|
|
|
|
|
y.Value));
|
|
|
|
|
if (!y.HasValue) continue;
|
|
|
|
|
|
|
|
|
|
if (series.Points.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
// 트래커 포맷: 시간, 수조, 지표, 값
|
|
|
|
|
series.TrackerFormatString =
|
|
|
|
|
$"시간: {{2:HH:mm}}\n{yField.Display}: {{4:0.###}}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (selectedTab.Equals(MonitorTab.Sterilizer))
|
|
|
|
|
{
|
|
|
|
|
series.Points.Add(new DataPoint(
|
|
|
|
|
DateTimeAxis.ToDouble(r.RecordedTime),
|
|
|
|
|
y.Value));
|
|
|
|
|
|
|
|
|
|
if (series.Points.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
// 트래커 포맷: 시간, 수조, 지표, 값
|
|
|
|
|
series.TrackerFormatString =
|
|
|
|
|
$"시간: {{2:HH:mm}}\n{yField.Display}: {{4:0.###}}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -150,6 +150,151 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void SetBoxPlot(
|
|
|
|
|
List<WaterQualityVO> rows,
|
|
|
|
|
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 series = new BoxPlotSeries()
|
|
|
|
|
{
|
|
|
|
|
BoxWidth = boxWidth
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (rows == null || rows.Count == 0) return;
|
|
|
|
|
|
|
|
|
|
var bucket = timeBucket ?? TimeSpan.FromHours(1);
|
|
|
|
|
|
|
|
|
|
var col = rows
|
|
|
|
|
.OrderBy(r => r.RecordedTime)
|
|
|
|
|
.GroupBy(g => ResolveTankX(g, xAxisKind.Name, bucket))
|
|
|
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
|
|
|
|
|
|
foreach(var (key, data) in col.OrderBy(x => x.Key))
|
|
|
|
|
{
|
|
|
|
|
// 1) 수조/시간별 값 리스트
|
|
|
|
|
var values = data
|
|
|
|
|
.Select(d => ResolveTankY(d, valueField.Name!))
|
|
|
|
|
.OfType<double>() // object→double
|
|
|
|
|
.Where(v => !double.IsNaN(v) && !double.IsInfinity(v))
|
|
|
|
|
.OrderBy(v => v)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
if (values.Count == 0) continue;
|
|
|
|
|
|
|
|
|
|
// 2) 사분위수/중앙값 계산
|
|
|
|
|
double q1 = Percentile(values, 0.25);
|
|
|
|
|
double median = Percentile(values, 0.50);
|
|
|
|
|
double q3 = Percentile(values, 0.75);
|
|
|
|
|
double iqr = q3 - q1;
|
|
|
|
|
|
|
|
|
|
// 3) 수염(윗/아랫 경계)과 이상치(Tukey 1.5*IQR)
|
|
|
|
|
double lowerFence = q1 - 1.5 * iqr;
|
|
|
|
|
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축 인덱스)
|
|
|
|
|
string label = xAxisKind.Name switch
|
|
|
|
|
{
|
|
|
|
|
"수조" or "Tank" => $"수조 {key}",
|
|
|
|
|
"시간" or "RecordedTime" => FormatBucket(FloorToBucket(data.First().RecordedTime, bucket), bucket),
|
|
|
|
|
_ => key
|
|
|
|
|
};
|
|
|
|
|
xAxis.Labels.Add(label);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//// [X] = 값 리스트 (그룹 없음)
|
|
|
|
|
//var byX = new Dictionary<string, List<double>>();
|
|
|
|
|
|
|
|
|
|
//// byGroup[gKey][xKey] = 값 리스트
|
|
|
|
|
|
|
|
|
|
//foreach (var r in rows)
|
|
|
|
|
//{
|
|
|
|
|
// var val = (double)ResolveTankY(r, valueField.Name);
|
|
|
|
|
// if (double.IsNaN(val) || double.IsInfinity(val)) continue;
|
|
|
|
|
|
|
|
|
|
// string xKey = ResolveTankX(r, xAxisKind.Name, bucket);
|
|
|
|
|
// if (!byX.TryGetValue(xKey, out var list))
|
|
|
|
|
// byX[xKey] = list = new List<double>();
|
|
|
|
|
|
|
|
|
|
// list.Add(val);
|
|
|
|
|
//}
|
|
|
|
|
|
|
|
|
|
//// X축 라벨(정렬) + 너무 많으면 최근 N개만
|
|
|
|
|
//var xKeys = byX.Keys.OrderBy(k => k, new KeyComparer()).ToList();
|
|
|
|
|
//if (xKeys.Count == 0) { Model.InvalidatePlot(true); return; }
|
|
|
|
|
//if (xKeys.Count > maxCategories)
|
|
|
|
|
// xKeys = xKeys.Skip(xKeys.Count - maxCategories).ToList();
|
|
|
|
|
|
|
|
|
|
//xAxis.Labels.Clear();
|
|
|
|
|
//xAxis.Labels.AddRange(xKeys);
|
|
|
|
|
|
|
|
|
|
//var series = new BoxPlotSeries { BoxWidth = boxWidth };
|
|
|
|
|
|
|
|
|
|
//for (int xi = 0; xi < xKeys.Count; xi++)
|
|
|
|
|
//{
|
|
|
|
|
// var list = byX[xKeys[xi]];
|
|
|
|
|
// if (list == null || list.Count == 0) continue;
|
|
|
|
|
|
|
|
|
|
// list.Sort();
|
|
|
|
|
|
|
|
|
|
// // 표본 1개도 표시(퇴화 박스)
|
|
|
|
|
// if (list.Count == 1)
|
|
|
|
|
// {
|
|
|
|
|
// double v = list[0];
|
|
|
|
|
// series.Items.Add(new BoxPlotItem(xi, v, v, v, v, v));
|
|
|
|
|
// continue;
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// var (lw, q1, med, q3, uw, outs) = Summarize(list);
|
|
|
|
|
// var item = new BoxPlotItem(xi, lw, q1, med, q3, uw);
|
|
|
|
|
// foreach (var o in outs) item.Outliers.Add(o);
|
|
|
|
|
// series.Items.Add(item);
|
|
|
|
|
//}
|
|
|
|
|
|
|
|
|
|
Model.Series.Add(series);
|
|
|
|
|
Model.IsLegendVisible = false;
|
|
|
|
|
Model.InvalidatePlot(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static 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? ResolveTankY(WaterQualityVO vo, string fieldName)
|
|
|
|
|
{
|
|
|
|
|
return fieldName switch
|
|
|
|
|
@ -177,5 +322,86 @@ namespace SmartAquaViewer.ViewModel
|
|
|
|
|
_ => null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private double? ResolveSterilizerY(WaterQualityVO vo, string fieldName)
|
|
|
|
|
{
|
|
|
|
|
return fieldName switch
|
|
|
|
|
{
|
|
|
|
|
"Sterilizing.OzoneDissolverPressure" => vo.Sterilizing.OzoneDissolverPressure,
|
|
|
|
|
_ => null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string ResolveTankX(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null)
|
|
|
|
|
{
|
|
|
|
|
return fieldName switch
|
|
|
|
|
{
|
|
|
|
|
"수조" or "Tank.Number" => vo.Tank.Number.ToString(),
|
|
|
|
|
"시간" or "RecordedTime" => FormatBucket(FloorToBucket(vo.RecordedTime, (TimeSpan)bucket), (TimeSpan)bucket),
|
|
|
|
|
_ => ""
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 string ResolveGroupKey(WaterQualityVO vo, FieldItem gf)
|
|
|
|
|
{
|
|
|
|
|
return gf.Name switch
|
|
|
|
|
{
|
|
|
|
|
"수조" or "Tank" => vo.Tank.Number.ToString(),
|
|
|
|
|
"시간" or "RecordedTime" => vo.RecordedTime.ToString("HH:mm"),
|
|
|
|
|
_ => gf.Name
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 lw, double q1, double med, double q3, double uw, List<double> outs) Summarize(IList<double> values)
|
|
|
|
|
{
|
|
|
|
|
double q1 = Percentile(values, 0.25);
|
|
|
|
|
double med = Percentile(values, 0.50);
|
|
|
|
|
double q3 = Percentile(values, 0.75);
|
|
|
|
|
double iqr = q3 - q1;
|
|
|
|
|
|
|
|
|
|
double lf = q1 - 1.5 * iqr;
|
|
|
|
|
double uf = q3 + 1.5 * iqr;
|
|
|
|
|
|
|
|
|
|
var inliers = values.Where(v => v >= lf && v <= uf).ToList();
|
|
|
|
|
var outs = values.Where(v => v < lf || v > uf).ToList();
|
|
|
|
|
|
|
|
|
|
double lw = (inliers.Count > 0 ? inliers.First() : values.First());
|
|
|
|
|
double uw = (inliers.Count > 0 ? inliers.Last() : values.Last());
|
|
|
|
|
return (lw, q1, med, q3, uw, outs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|