diff --git a/SmartAquaViewer/View/MonitoringView.xaml b/SmartAquaViewer/View/MonitoringView.xaml index 7b6744b..5ecb1ef 100644 --- a/SmartAquaViewer/View/MonitoringView.xaml +++ b/SmartAquaViewer/View/MonitoringView.xaml @@ -245,6 +245,13 @@ + @@ -342,48 +349,57 @@ - - - - - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + + + + + - - - - + + + + + + + + + + + diff --git a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs index 170346e..0acbb4d 100644 --- a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs +++ b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs @@ -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 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() // 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>(); + + //// 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(); + + // 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 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 outs) Summarize(IList 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 + { + public int Compare(string? a, string? b) + { + if (a == null || b == null) return Comparer.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); + } + } } } diff --git a/SmartAquaViewer/ViewModel/MonitoringViewModel.cs b/SmartAquaViewer/ViewModel/MonitoringViewModel.cs index c0af6a2..54e53d6 100644 --- a/SmartAquaViewer/ViewModel/MonitoringViewModel.cs +++ b/SmartAquaViewer/ViewModel/MonitoringViewModel.cs @@ -180,15 +180,6 @@ namespace SmartAquaViewer.ViewModel set { if (_selectedYField != value) { _selectedYField = value; OnPropertyChanged(); } } } - // BoxPlot 전용 그룹 기준(예: 수조/시간 등) - public ObservableCollection GroupFieldCandidates { get; } = new(); - private FieldItem? _selectedGroupField; - public FieldItem? SelectedGroupField - { - get => _selectedGroupField; - set { if (_selectedGroupField != value) { _selectedGroupField = value; OnPropertyChanged(); } } - } - // [옵션] 예시 — 필요하면 추가 private bool _showMarkers; // Line public bool ShowMarkers { get => _showMarkers; set { _showMarkers = value; OnPropertyChanged(); } } @@ -204,6 +195,20 @@ namespace SmartAquaViewer.ViewModel private double _boxWidth = 0.3; // Box public double BoxWidth { get => _boxWidth; set { _boxWidth = value; OnPropertyChanged(); } } + + private int _boxTimeSpan = 6; // Box + public int BoxTimeSpan + { + get => _boxTimeSpan; + set + { + if (_boxTimeSpan != value) + { + _boxTimeSpan = value; + OnPropertyChanged(); + } + } + } #endregion public ICommand ChangeDrawerStatusCommand { get; } @@ -244,22 +249,21 @@ namespace SmartAquaViewer.ViewModel private void DrawGraph(object obj) { - var data = SetGraphData(); - switch (SelectedGraphType) { case GraphType.LINE: var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var yField = SelectedYField; var isMarker = ShowMarkers; - if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(data, xField, yField, isMarker); + if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(SelectedWaterTanks, xField, yField, isMarker); else GraphControlVM.SetDefaultLineGraph(WaterQualityList, SelectedTab, xField, yField, isMarker); break; case GraphType.BOX: var xFieldBox = SelectedXField; var dataFieldBox = SelectedYField; - var boxGroup = SelectedGroupField; var boxWidth = BoxWidth; + var boxTimeSpan = TimeSpan.FromHours(BoxTimeSpan); + GraphControlVM.SetBoxPlot(WaterQualityList, xFieldBox, dataFieldBox, boxWidth, boxTimeSpan); break; case GraphType.SCATTER: var xFieldScatter = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; @@ -283,26 +287,6 @@ namespace SmartAquaViewer.ViewModel GraphControlVM.SetTankLineGraph(collection, xField, yField, showMarkers); } - private object SetGraphData() - { - object data = null; - - switch (SelectedTab) - { - case MonitorTab.Tank: - data = SelectedWaterTanks; - break; - case MonitorTab.Filter: - case MonitorTab.Sterilizer: - data = WaterQualityList; - break; - default: - break; - } - - return data; - } - private void SetGraphType() { GraphTypes.Clear(); @@ -369,13 +353,13 @@ namespace SmartAquaViewer.ViewModel // 후보 초기화 XFieldCandidates.Clear(); YFieldCandidates.Clear(); - GroupFieldCandidates.Clear(); // X축: 시간 우선 foreach (var f in AvailableFields) { XFieldCandidates.Add(f); if (SelectedGraphType == GraphType.LINE || SelectedGraphType == GraphType.STEP) break; + if (SelectedGraphType == GraphType.BOX && f.Name.Equals("Tank.Number")) break; } SelectedXField = AvailableFields.FirstOrDefault(f => f.DataType == typeof(DateTime)) ?? AvailableFields.FirstOrDefault(); @@ -384,14 +368,9 @@ namespace SmartAquaViewer.ViewModel foreach (var f in AvailableFields.Where(f => f.DataType == typeof(double))) YFieldCandidates.Add(f); - // BoxPlot 그룹 후보: 시간/수조/카테고리 등 - foreach (var f in AvailableFields) - GroupFieldCandidates.Add(f); - // 기본 선택 세팅 (타입별) SelectedYFields.Clear(); SelectedYField = null; - SelectedGroupField = null; switch (SelectedGraphType) { @@ -415,9 +394,6 @@ namespace SmartAquaViewer.ViewModel case GraphType.BOX: SelectedYField = YFieldCandidates.FirstOrDefault(); - // 그룹은 시간으로 기본 - SelectedGroupField = AvailableFields.FirstOrDefault(f => f.DataType == typeof(DateTime)) - ?? AvailableFields.FirstOrDefault(); BoxWidth = 0.3; break; }