From fecca36925edb777eae8f81a318c3f6e7c03e545 Mon Sep 17 00:00:00 2001 From: hj615 Date: Wed, 20 Aug 2025 10:45:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=B0=EC=A0=90=EB=8F=84=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SmartAquaViewer/View/MonitoringView.xaml | 82 +++++----- .../ViewModel/GraphControlViewModel.cs | 152 ++++++++++++------ .../ViewModel/MonitoringViewModel.cs | 12 +- 3 files changed, 149 insertions(+), 97 deletions(-) diff --git a/SmartAquaViewer/View/MonitoringView.xaml b/SmartAquaViewer/View/MonitoringView.xaml index 5ecb1ef..9b055a4 100644 --- a/SmartAquaViewer/View/MonitoringView.xaml +++ b/SmartAquaViewer/View/MonitoringView.xaml @@ -315,56 +315,46 @@ - - - - - - - - - - - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + + @@ -382,7 +372,21 @@ FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/> - + + + + + + + + + + diff --git a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs index 0acbb4d..87f327c 100644 --- a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs +++ b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs @@ -70,7 +70,7 @@ namespace SmartAquaViewer.ViewModel foreach (var r in data.OrderBy(r => r.RecordedTime)) { - var y = ResolveTankY(r, yField.Name!); + var y = ResolveTank(r, yField.Name!); if (!y.HasValue) continue; series.Points.Add(new DataPoint( @@ -128,9 +128,9 @@ namespace SmartAquaViewer.ViewModel { double? y = null; if (selectedTab.Equals(MonitorTab.Filter)) - y = ResolveFilterY(r, yField.Name!); + y = ResolveFilter(r, yField.Name!); else if (selectedTab.Equals(MonitorTab.Sterilizer)) - y = ResolveSterilizerY(r, yField.Name!); + y = ResolveSterilizer(r, yField.Name!); if (!y.HasValue) continue; @@ -191,14 +191,14 @@ namespace SmartAquaViewer.ViewModel var col = rows .OrderBy(r => r.RecordedTime) - .GroupBy(g => ResolveTankX(g, xAxisKind.Name, bucket)) + .GroupBy(g => ResolveTankOrTime(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!)) + .Select(d => ResolveTank(d, valueField.Name!)) .OfType() // object→double .Where(v => !double.IsNaN(v) && !double.IsInfinity(v)) .OrderBy(v => v) @@ -234,71 +234,99 @@ namespace SmartAquaViewer.ViewModel 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(); + Model.Series.Add(series); + Model.IsLegendVisible = false; + Model.InvalidatePlot(true); + } - // list.Add(val); - //} + public void SetScatterPlot( + List rows, + FieldItem xAxisField, + FieldItem yAxisField, + double markerSize = 3, + bool showRegression = false) + { + Model.Series.Clear(); + Model.Axes.Clear(); - //// 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(); + var xAxis = new LinearAxis + { + Position = AxisPosition.Bottom, + Title = xAxisField.Display, + MajorGridlineStyle = LineStyle.Solid, + MinorGridlineStyle = LineStyle.Dot + }; + var yAxis = new LinearAxis + { + Position = AxisPosition.Left, + Title = yAxisField.Display, + MajorGridlineStyle = LineStyle.Solid, + MinorGridlineStyle = LineStyle.Dot + }; - //xAxis.Labels.Clear(); - //xAxis.Labels.AddRange(xKeys); + Model.Axes.Add(xAxis); + Model.Axes.Add(yAxis); - //var series = new BoxPlotSeries { BoxWidth = boxWidth }; + var scatterSeries = new ScatterSeries + { + MarkerType = MarkerType.Circle, + MarkerSize = markerSize, + MarkerStroke = OxyColors.Black, + MarkerFill = OxyColors.DeepSkyBlue + }; - //for (int xi = 0; xi < xKeys.Count; xi++) - //{ - // var list = byX[xKeys[xi]]; - // if (list == null || list.Count == 0) continue; + foreach (var row in rows) + { + double x = ResolveTank(row, xAxisField.Name) ?? double.NaN; + double y = ResolveTank(row, yAxisField.Name) ?? double.NaN; + scatterSeries.Points.Add(new ScatterPoint(x, y)); + } - // list.Sort(); + Model.Series.Add(scatterSeries); - // // 표본 1개도 표시(퇴화 박스) - // if (list.Count == 1) - // { - // double v = list[0]; - // series.Items.Add(new BoxPlotItem(xi, v, v, v, v, v)); - // continue; - // } + if (showRegression && rows.Count > 1) + { + var points = rows.Select(r + => new DataPoint( + ResolveTank(r, xAxisField.Name) ?? double.NaN, + ResolveTank(r, yAxisField.Name) ?? double.NaN)) + .ToList(); + var regression = LinearRegression(points); - // 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); - //} + if (regression != null) + { + var lineSeries = new LineSeries + { + Title = "Regression", + Color = OxyColors.Red, + StrokeThickness = 2 + }; + + // 최소/최대 구간으로 선 그리기 + double minX = points.Min(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(maxX, regression.Value.Intercept + regression.Value.Slope * maxX)); + + Model.Series.Add(lineSeries); + } + } - Model.Series.Add(series); - Model.IsLegendVisible = false; Model.InvalidatePlot(true); } - private static DateTime FloorToBucket(DateTime dt, TimeSpan bucket) + 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? ResolveTankY(WaterQualityVO vo, string fieldName) + private double? ResolveTank(WaterQualityVO vo, string fieldName) { return fieldName switch { + "RecordedTime" => DateTimeAxis.ToDouble(vo.RecordedTime), "Tank.DOValue" => vo.Tank.DOValue, "Tank.PH" => vo.Tank.PH, "Tank.ORP" => vo.Tank.ORP, @@ -308,7 +336,7 @@ namespace SmartAquaViewer.ViewModel }; } - private double? ResolveFilterY(WaterQualityVO vo, string fieldName) + private double? ResolveFilter(WaterQualityVO vo, string fieldName) { return fieldName switch { @@ -323,7 +351,7 @@ namespace SmartAquaViewer.ViewModel }; } - private double? ResolveSterilizerY(WaterQualityVO vo, string fieldName) + private double? ResolveSterilizer(WaterQualityVO vo, string fieldName) { return fieldName switch { @@ -332,7 +360,7 @@ namespace SmartAquaViewer.ViewModel }; } - private string ResolveTankX(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null) + private string ResolveTankOrTime(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null) { return fieldName switch { @@ -390,6 +418,26 @@ namespace SmartAquaViewer.ViewModel return (lw, q1, med, q3, uw, outs); } + // 단순 선형회귀 계산 + private (double Slope, double Intercept)? LinearRegression(List 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 class KeyComparer : IComparer { public int Compare(string? a, string? b) diff --git a/SmartAquaViewer/ViewModel/MonitoringViewModel.cs b/SmartAquaViewer/ViewModel/MonitoringViewModel.cs index 54e53d6..deefae8 100644 --- a/SmartAquaViewer/ViewModel/MonitoringViewModel.cs +++ b/SmartAquaViewer/ViewModel/MonitoringViewModel.cs @@ -255,7 +255,7 @@ namespace SmartAquaViewer.ViewModel var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var yField = SelectedYField; var isMarker = ShowMarkers; - if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(SelectedWaterTanks, xField, yField, isMarker); + if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(xField, yField, isMarker); else GraphControlVM.SetDefaultLineGraph(WaterQualityList, SelectedTab, xField, yField, isMarker); break; case GraphType.BOX: @@ -266,25 +266,24 @@ namespace SmartAquaViewer.ViewModel GraphControlVM.SetBoxPlot(WaterQualityList, xFieldBox, dataFieldBox, boxWidth, boxTimeSpan); break; case GraphType.SCATTER: - var xFieldScatter = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; + var xFieldScatter = SelectedXField; var yFiledScatter = SelectedYField; var markerSIze = ScatterMarkerSize; var showRegression = ShowRegression; + GraphControlVM.SetScatterPlot(WaterQualityList, xFieldScatter, yFiledScatter, markerSIze, showRegression); break; default: break; } } - private void SetGraphData_Line_Tank(object data, FieldItem? xField, FieldItem? yField, bool showMarkers) + private void SetGraphData_Line_Tank(FieldItem? xField, FieldItem? yField, bool showMarkers) { if (SelectedTab != MonitorTab.Tank) return; if (xField?.Name != "RecordedTime" || yField == null) return; if (SelectedWaterTanks.Count == 0) return; - var collection = data as Dictionary>; - - GraphControlVM.SetTankLineGraph(collection, xField, yField, showMarkers); + GraphControlVM.SetTankLineGraph(SelectedWaterTanks, xField, yField, showMarkers); } private void SetGraphType() @@ -357,6 +356,7 @@ namespace SmartAquaViewer.ViewModel // X축: 시간 우선 foreach (var f in AvailableFields) { + if (SelectedGraphType == GraphType.SCATTER && f.Name.Equals("Tank.Number")) continue; XFieldCandidates.Add(f); if (SelectedGraphType == GraphType.LINE || SelectedGraphType == GraphType.STEP) break; if (SelectedGraphType == GraphType.BOX && f.Name.Equals("Tank.Number")) break;