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