feat: BoxPlot 생성 기능 추가

hhsung_work
HyungJune Kim 10 months ago
parent 41bff54c4f
commit e4f72f965f

@ -245,6 +245,13 @@
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style x:Key="VisibleWhenTime" TargetType="FrameworkElement" BasedOn="{StaticResource {x:Type FrameworkElement}}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedXField.Name}" Value="RecordedTime">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Resources> </Grid.Resources>
<!-- LINE/STEP: 다중 선택 --> <!-- LINE/STEP: 다중 선택 -->
@ -342,48 +349,57 @@
</Grid> </Grid>
<!-- BOX: 값 필드 + 그룹 필드 + 옵션 --> <!-- BOX: 값 필드 + 그룹 필드 + 옵션 -->
<Grid Style="{StaticResource VisibleWhenBox}"> <StackPanel Style="{StaticResource VisibleWhenBox}">
<Grid.RowDefinitions> <Grid>
<RowDefinition Height="70"/> <Grid.ColumnDefinitions>
<RowDefinition Height="70"/> <ColumnDefinition Width="80"/>
<RowDefinition Height="70"/> <ColumnDefinition/>
</Grid.RowDefinitions> </Grid.ColumnDefinitions>
<Grid.ColumnDefinitions> <TextBlock Text="값 필드" VerticalAlignment="Center"
<ColumnDefinition Width="80"/> FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ColumnDefinition/> <ComboBox ItemsSource="{Binding YFieldCandidates}"
<ColumnDefinition Width="Auto"/> SelectedItem="{Binding SelectedYField}"
</Grid.ColumnDefinitions> DisplayMemberPath="Display" Height="40"
<TextBlock Text="값 필드" VerticalAlignment="Center" Grid.Column="1" Margin="15 0 0 0"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/> Style="{StaticResource ComboBoxStyle}"/>
<ComboBox ItemsSource="{Binding YFieldCandidates}" </Grid>
SelectedItem="{Binding SelectedYField}"
DisplayMemberPath="Display" Height="50"
Grid.Column="1" Margin="15 0 0 0"
Style="{StaticResource ComboBoxStyle}"/>
<TextBlock VerticalAlignment="Center" Grid.Row="1" <Grid Grid.Row="1" Style="{StaticResource VisibleWhenTime}" Margin="0 15">
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"> <Grid.ColumnDefinitions>
<Run Text="그룹"/> <ColumnDefinition Width="80"/>
<LineBreak/> <ColumnDefinition/>
<Run Text="(박스 기준)" FontSize="14"/> <ColumnDefinition Width="Auto"/>
</TextBlock> </Grid.ColumnDefinitions>
<ComboBox ItemsSource="{Binding GroupFieldCandidates}" <TextBlock Text="시간 범위" VerticalAlignment="Center"
SelectedItem="{Binding SelectedGroupField}" FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
DisplayMemberPath="Display" Height="50" <Slider Margin="15 0 0 0" Grid.Column="1"
Grid.Row="1" Grid.Column="1" Margin="15 0 0 0" VerticalAlignment="Center" HorizontalAlignment="Left"
Style="{StaticResource ComboBoxStyle}"/> Minimum="1" Maximum="24" TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding BoxTimeSpan}" Width="310"/>
<TextBlock Text="{Binding BoxTimeSpan}"
Margin="15 0" Grid.Column="2"
VerticalAlignment="Center" HorizontalAlignment="Right"
FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
</Grid>
<TextBlock Text="박스 너비" VerticalAlignment="Center" Grid.Row="2" <Grid Grid.Row="2">
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/> <Grid.ColumnDefinitions>
<Slider Margin="15 0 0 0" Grid.Row="2" Grid.Column="1" <ColumnDefinition Width="80"/>
VerticalAlignment="Center" HorizontalAlignment="Left" <ColumnDefinition/>
Minimum="0.1" Maximum="1.0" TickFrequency="0.05" IsSnapToTickEnabled="True" <ColumnDefinition Width="Auto"/>
Value="{Binding BoxWidth}" Width="310"/> </Grid.ColumnDefinitions>
<TextBlock Text="{Binding BoxWidth, StringFormat=F2}" <TextBlock Text="박스 너비" VerticalAlignment="Center"
Margin="15 0" Grid.Row="2" Grid.Column="2" FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
VerticalAlignment="Center" HorizontalAlignment="Right" <Slider Margin="15 0 0 0" Grid.Column="1"
FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/> VerticalAlignment="Center" HorizontalAlignment="Left"
</Grid> Minimum="0.1" Maximum="1.0" TickFrequency="0.05" IsSnapToTickEnabled="True"
Value="{Binding BoxWidth}" Width="310"/>
<TextBlock Text="{Binding BoxWidth, StringFormat=F2}"
Margin="15 0" Grid.Column="2"
VerticalAlignment="Center" HorizontalAlignment="Right"
FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
</Grid>
</StackPanel>
</Grid> </Grid>
</StackPanel> </StackPanel>

@ -4,8 +4,11 @@ using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; 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.Series; using OxyPlot.Series;
using SmartAquaViewer.DataAnalysis; using SmartAquaViewer.DataAnalysis;
using static SmartAquaViewer.Model.Enums; using static SmartAquaViewer.Model.Enums;
@ -99,7 +102,6 @@ namespace SmartAquaViewer.ViewModel
var xAxis = new DateTimeAxis var xAxis = new DateTimeAxis
{ {
Position = AxisPosition.Bottom, Position = AxisPosition.Bottom,
Title = "시간",
StringFormat = "HH:mm:ss", StringFormat = "HH:mm:ss",
IntervalType = DateTimeIntervalType.Minutes, IntervalType = DateTimeIntervalType.Minutes,
MajorGridlineStyle = LineStyle.Solid, MajorGridlineStyle = LineStyle.Solid,
@ -124,25 +126,23 @@ namespace SmartAquaViewer.ViewModel
foreach (var r in collection.OrderBy(r => r.RecordedTime)) foreach (var r in collection.OrderBy(r => r.RecordedTime))
{ {
double? y = null;
if (selectedTab.Equals(MonitorTab.Filter)) if (selectedTab.Equals(MonitorTab.Filter))
{ y = ResolveFilterY(r, yField.Name!);
var y = ResolveFilterY(r, yField.Name!); else if (selectedTab.Equals(MonitorTab.Sterilizer))
if (!y.HasValue) continue; y = ResolveSterilizerY(r, yField.Name!);
series.Points.Add(new DataPoint( if (!y.HasValue) continue;
DateTimeAxis.ToDouble(r.RecordedTime),
y.Value));
if (series.Points.Count > 0) series.Points.Add(new DataPoint(
{ DateTimeAxis.ToDouble(r.RecordedTime),
// 트래커 포맷: 시간, 수조, 지표, 값 y.Value));
series.TrackerFormatString =
$"시간: {{2:HH:mm}}\n{yField.Display}: {{4:0.###}}";
}
}
else if (selectedTab.Equals(MonitorTab.Sterilizer))
{
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); 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) private double? ResolveTankY(WaterQualityVO vo, string fieldName)
{ {
return fieldName switch return fieldName switch
@ -177,5 +322,86 @@ namespace SmartAquaViewer.ViewModel
_ => null _ => 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);
}
}
} }
} }

@ -180,15 +180,6 @@ namespace SmartAquaViewer.ViewModel
set { if (_selectedYField != value) { _selectedYField = value; OnPropertyChanged(); } } set { if (_selectedYField != value) { _selectedYField = value; OnPropertyChanged(); } }
} }
// BoxPlot 전용 그룹 기준(예: 수조/시간 등)
public ObservableCollection<FieldItem> GroupFieldCandidates { get; } = new();
private FieldItem? _selectedGroupField;
public FieldItem? SelectedGroupField
{
get => _selectedGroupField;
set { if (_selectedGroupField != value) { _selectedGroupField = value; OnPropertyChanged(); } }
}
// [옵션] 예시 — 필요하면 추가 // [옵션] 예시 — 필요하면 추가
private bool _showMarkers; // Line private bool _showMarkers; // Line
public bool ShowMarkers { get => _showMarkers; set { _showMarkers = value; OnPropertyChanged(); } } public bool ShowMarkers { get => _showMarkers; set { _showMarkers = value; OnPropertyChanged(); } }
@ -204,6 +195,20 @@ namespace SmartAquaViewer.ViewModel
private double _boxWidth = 0.3; // Box private double _boxWidth = 0.3; // Box
public double BoxWidth { get => _boxWidth; set { _boxWidth = value; OnPropertyChanged(); } } 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 #endregion
public ICommand ChangeDrawerStatusCommand { get; } public ICommand ChangeDrawerStatusCommand { get; }
@ -244,22 +249,21 @@ namespace SmartAquaViewer.ViewModel
private void DrawGraph(object obj) private void DrawGraph(object obj)
{ {
var data = SetGraphData();
switch (SelectedGraphType) switch (SelectedGraphType)
{ {
case GraphType.LINE: case GraphType.LINE:
var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null;
var yField = SelectedYField; var yField = SelectedYField;
var isMarker = ShowMarkers; 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); else GraphControlVM.SetDefaultLineGraph(WaterQualityList, SelectedTab, xField, yField, isMarker);
break; break;
case GraphType.BOX: case GraphType.BOX:
var xFieldBox = SelectedXField; var xFieldBox = SelectedXField;
var dataFieldBox = SelectedYField; var dataFieldBox = SelectedYField;
var boxGroup = SelectedGroupField;
var boxWidth = BoxWidth; var boxWidth = BoxWidth;
var boxTimeSpan = TimeSpan.FromHours(BoxTimeSpan);
GraphControlVM.SetBoxPlot(WaterQualityList, xFieldBox, dataFieldBox, boxWidth, boxTimeSpan);
break; break;
case GraphType.SCATTER: case GraphType.SCATTER:
var xFieldScatter = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var xFieldScatter = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null;
@ -283,26 +287,6 @@ namespace SmartAquaViewer.ViewModel
GraphControlVM.SetTankLineGraph(collection, xField, yField, showMarkers); 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() private void SetGraphType()
{ {
GraphTypes.Clear(); GraphTypes.Clear();
@ -369,13 +353,13 @@ namespace SmartAquaViewer.ViewModel
// 후보 초기화 // 후보 초기화
XFieldCandidates.Clear(); XFieldCandidates.Clear();
YFieldCandidates.Clear(); YFieldCandidates.Clear();
GroupFieldCandidates.Clear();
// X축: 시간 우선 // X축: 시간 우선
foreach (var f in AvailableFields) foreach (var f in AvailableFields)
{ {
XFieldCandidates.Add(f); XFieldCandidates.Add(f);
if (SelectedGraphType == GraphType.LINE || SelectedGraphType == GraphType.STEP) break; 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)) SelectedXField = AvailableFields.FirstOrDefault(f => f.DataType == typeof(DateTime))
?? AvailableFields.FirstOrDefault(); ?? AvailableFields.FirstOrDefault();
@ -384,14 +368,9 @@ namespace SmartAquaViewer.ViewModel
foreach (var f in AvailableFields.Where(f => f.DataType == typeof(double))) foreach (var f in AvailableFields.Where(f => f.DataType == typeof(double)))
YFieldCandidates.Add(f); YFieldCandidates.Add(f);
// BoxPlot 그룹 후보: 시간/수조/카테고리 등
foreach (var f in AvailableFields)
GroupFieldCandidates.Add(f);
// 기본 선택 세팅 (타입별) // 기본 선택 세팅 (타입별)
SelectedYFields.Clear(); SelectedYFields.Clear();
SelectedYField = null; SelectedYField = null;
SelectedGroupField = null;
switch (SelectedGraphType) switch (SelectedGraphType)
{ {
@ -415,9 +394,6 @@ namespace SmartAquaViewer.ViewModel
case GraphType.BOX: case GraphType.BOX:
SelectedYField = YFieldCandidates.FirstOrDefault(); SelectedYField = YFieldCandidates.FirstOrDefault();
// 그룹은 시간으로 기본
SelectedGroupField = AvailableFields.FirstOrDefault(f => f.DataType == typeof(DateTime))
?? AvailableFields.FirstOrDefault();
BoxWidth = 0.3; BoxWidth = 0.3;
break; break;
} }

Loading…
Cancel
Save