feat: Feedforward advisory engine Phase I — core C# models/blocks/engine/supervisor/store/controller
This commit is contained in:
71
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal file
71
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum StreamRole { Commanded, LevelDriven, Monitor }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum Confidence { A, B, C }
|
||||||
|
|
||||||
|
public sealed record StreamConfig
|
||||||
|
{
|
||||||
|
public string Key { get; init; } = "";
|
||||||
|
public string FlowTag { get; init; } = "";
|
||||||
|
public StreamRole Role { get; init; } = StreamRole.Monitor;
|
||||||
|
public string? LevelTag { get; init; }
|
||||||
|
public double TargetCoeff { get; init; }
|
||||||
|
public double ThetaUpSec { get; init; }
|
||||||
|
public double ThetaDnSec { get; init; }
|
||||||
|
public double TauSec { get; init; }
|
||||||
|
public double SpMin { get; init; }
|
||||||
|
public double SpMax { get; init; } = double.MaxValue;
|
||||||
|
public double RateUpPerMin { get; init; } = double.MaxValue;
|
||||||
|
public double RateDnPerMin { get; init; } = double.MaxValue;
|
||||||
|
public bool RefluxFromProduct { get; init; }
|
||||||
|
public Confidence Grade { get; init; } = Confidence.A;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ColumnConfig
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public bool AdvisoryOnly { get; init; } = true;
|
||||||
|
public string FeedTag { get; init; } = "";
|
||||||
|
public string? PressureTag { get; init; }
|
||||||
|
public IReadOnlyList<string> LevelTags { get; init; } = Array.Empty<string>();
|
||||||
|
public double ScanSec { get; init; } = 2.0;
|
||||||
|
public double FeedFilterTauSec { get; init; } = 300.0;
|
||||||
|
public double FeedMoveThresholdPerMin { get; init; } = 0.0;
|
||||||
|
public double PressFilterTauSec { get; init; } = 60.0;
|
||||||
|
public double PressureBand { get; init; } = double.MaxValue;
|
||||||
|
public double SettleSec { get; init; } = 0.0;
|
||||||
|
public double StaleSec { get; init; } = 120.0;
|
||||||
|
public string? ProductKey { get; init; } = "P";
|
||||||
|
public IReadOnlyList<StreamConfig> Streams { get; init; } = Array.Empty<StreamConfig>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp);
|
||||||
|
|
||||||
|
public sealed record PvSnapshot(
|
||||||
|
TagSample Feed,
|
||||||
|
TagSample? Pressure,
|
||||||
|
IReadOnlyList<TagSample> Levels,
|
||||||
|
IReadOnlyDictionary<string, TagSample> Streams);
|
||||||
|
|
||||||
|
public sealed record StreamAdvisory(
|
||||||
|
string Key, string FlowTag, StreamRole Role,
|
||||||
|
double Pv, double? RecommendedSp, double? Gap,
|
||||||
|
int Trend,
|
||||||
|
bool Valid,
|
||||||
|
Confidence Grade,
|
||||||
|
string? LevelTag,
|
||||||
|
string Note);
|
||||||
|
|
||||||
|
public sealed record AdvisoryResult(
|
||||||
|
int ColumnId, string ColumnName, DateTime ComputedAt,
|
||||||
|
bool Enabled, bool Transient, string TransientReason,
|
||||||
|
double FeedFiltered,
|
||||||
|
IReadOnlyList<StreamAdvisory> Streams,
|
||||||
|
double? VLoss, double? Yield, string MassBalanceState);
|
||||||
15
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal file
15
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
|
||||||
|
public interface IFeedforwardConfigStore
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
|
||||||
|
Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default);
|
||||||
|
Task DeleteColumnAsync(int columnId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IFeedforwardAdvisoryStore
|
||||||
|
{
|
||||||
|
void Set(AdvisoryResult result);
|
||||||
|
AdvisoryResult? Get(int columnId);
|
||||||
|
IReadOnlyCollection<AdvisoryResult> GetAll();
|
||||||
|
}
|
||||||
123
src/Infrastructure/Control/ComputationBlocks.cs
Normal file
123
src/Infrastructure/Control/ComputationBlocks.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
namespace ExperionCrawler.Infrastructure.Control;
|
||||||
|
|
||||||
|
public static class Num
|
||||||
|
{
|
||||||
|
public static double Clamp(double x, double lo, double hi) => Math.Max(lo, Math.Min(hi, x));
|
||||||
|
public static bool IsFinite(double x) => !double.IsNaN(x) && !double.IsInfinity(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FirstOrderLag
|
||||||
|
{
|
||||||
|
private double _y;
|
||||||
|
private bool _seeded;
|
||||||
|
public double Value => _y;
|
||||||
|
public bool Seeded => _seeded;
|
||||||
|
public void Seed(double v) { _y = v; _seeded = true; }
|
||||||
|
public double Step(double x, double tauSec, double tsSec)
|
||||||
|
{
|
||||||
|
if (!_seeded) { Seed(x); return _y; }
|
||||||
|
if (tauSec <= 0.0) { _y = x; return _y; }
|
||||||
|
var a = tsSec / (tauSec + tsSec);
|
||||||
|
_y += (x - _y) * a;
|
||||||
|
return _y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MovingAverage
|
||||||
|
{
|
||||||
|
private readonly Queue<double> _buf = new();
|
||||||
|
private readonly int _window;
|
||||||
|
private double _sum;
|
||||||
|
public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples);
|
||||||
|
public double Push(double x)
|
||||||
|
{
|
||||||
|
_buf.Enqueue(x); _sum += x;
|
||||||
|
while (_buf.Count > _window) _sum -= _buf.Dequeue();
|
||||||
|
return _sum / _buf.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 가변 전달지연(데드타임) 링버퍼. 용량은 요청된 최대 n 으로 **증가만**(축소 금지)하며
|
||||||
|
/// θ(=n)가 스캔마다 바뀌어도(비대칭 θup/θdn, D7) **읽기 오프셋만 가변** — 히스토리를 보존한다.
|
||||||
|
/// (이전 구현은 n 변경 시 Resize 로 버퍼를 재시드해 비대칭 θ에서 지연선이 매 부호반전마다 소실됨)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeadTimeBuffer
|
||||||
|
{
|
||||||
|
private double[] _buf = Array.Empty<double>(); // 항상 가득 찬 링(시드로 사전충전)
|
||||||
|
private int _cap; // 용량(보존 샘플 수). 증가만.
|
||||||
|
private int _head; // 다음 덮어쓸 위치(=가장 오래된 값)
|
||||||
|
private bool _seeded;
|
||||||
|
|
||||||
|
public double Through(double x, double thetaSec, double tsSec)
|
||||||
|
{
|
||||||
|
int n = (int)Math.Round(thetaSec / Math.Max(1e-6, tsSec));
|
||||||
|
if (n <= 0) return x; // θ<ts → 지연 없음
|
||||||
|
|
||||||
|
EnsureCapacity(n + 1, x); // n 샘플 지연엔 n+1 슬롯 필요
|
||||||
|
|
||||||
|
_buf[_head] = x; // 가장 오래된 자리에 현재값 기록
|
||||||
|
int newest = _head;
|
||||||
|
_head = (_head + 1) % _cap;
|
||||||
|
|
||||||
|
int idx = ((newest - n) % _cap + _cap) % _cap; // n 스캔 전 값
|
||||||
|
return _buf[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>용량을 need 이상으로 확보(증가 전용). 기존 이력은 논리순서로 보존.</summary>
|
||||||
|
private void EnsureCapacity(int need, double seed)
|
||||||
|
{
|
||||||
|
if (_seeded && need <= _cap) return;
|
||||||
|
int newCap = Math.Max(need, Math.Max(_cap, 1));
|
||||||
|
var nb = new double[newCap];
|
||||||
|
if (!_seeded)
|
||||||
|
{
|
||||||
|
Array.Fill(nb, seed); // 최초: 현재값으로 사전충전(bumpless)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int extra = newCap - _cap;
|
||||||
|
double oldest = _buf[_head];
|
||||||
|
for (int i = 0; i < extra; i++) nb[i] = oldest; // 앞쪽 패딩=가장 오래된 값
|
||||||
|
for (int k = 0; k < _cap; k++) nb[extra + k] = _buf[(_head + k) % _cap]; // 오래된→최신
|
||||||
|
}
|
||||||
|
_buf = nb; _cap = newCap; _head = 0; // head=0: 인덱스0이 가장 오래된, cap-1이 최신
|
||||||
|
_seeded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RateLimiter
|
||||||
|
{
|
||||||
|
private double _last;
|
||||||
|
private bool _seeded;
|
||||||
|
public double Last => _last;
|
||||||
|
public void Seed(double v) { _last = v; _seeded = true; }
|
||||||
|
public double Step(double target, double rateUpPerMin, double rateDnPerMin, double tsSec)
|
||||||
|
{
|
||||||
|
if (!_seeded) { Seed(target); return _last; }
|
||||||
|
var up = Math.Abs(rateUpPerMin) * tsSec / 60.0;
|
||||||
|
var dn = Math.Abs(rateDnPerMin) * tsSec / 60.0;
|
||||||
|
var d = Num.Clamp(target - _last, -dn, up);
|
||||||
|
_last += d;
|
||||||
|
return _last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Derivative
|
||||||
|
{
|
||||||
|
private double _prev;
|
||||||
|
private bool _seeded;
|
||||||
|
public double Update(double x, double tsSec)
|
||||||
|
{
|
||||||
|
if (!_seeded) { _prev = x; _seeded = true; return 0.0; }
|
||||||
|
var d = (x - _prev) / Math.Max(1e-6, tsSec);
|
||||||
|
_prev = x;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TempCorrection
|
||||||
|
{
|
||||||
|
public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
|
||||||
|
=> tMeas - dTdP * (p - pRef);
|
||||||
|
}
|
||||||
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal file
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Control;
|
||||||
|
|
||||||
|
public sealed class FeedforwardAdvisoryStore : IFeedforwardAdvisoryStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<int, AdvisoryResult> _latest = new();
|
||||||
|
public void Set(AdvisoryResult r) => _latest[r.ColumnId] = r;
|
||||||
|
public AdvisoryResult? Get(int id) => _latest.TryGetValue(id, out var r) ? r : null;
|
||||||
|
public IReadOnlyCollection<AdvisoryResult> GetAll() => _latest.Values.ToArray();
|
||||||
|
}
|
||||||
196
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal file
196
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
using ExperionCrawler.Infrastructure.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Control;
|
||||||
|
|
||||||
|
public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||||
|
{
|
||||||
|
private readonly ExperionDbContext _ctx;
|
||||||
|
private readonly ILogger<FeedforwardConfigStore> _logger;
|
||||||
|
|
||||||
|
public FeedforwardConfigStore(ExperionDbContext ctx, ILogger<FeedforwardConfigStore> logger)
|
||||||
|
{ _ctx = ctx; _logger = logger; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = _ctx.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
var cols = new Dictionary<int, (ColumnConfig cfg, List<StreamConfig> streams)>();
|
||||||
|
await using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
|
||||||
|
feed_filter_tau_sec, feed_move_thr_per_min,
|
||||||
|
press_filter_tau_sec, pressure_band, settle_sec,
|
||||||
|
stale_sec, product_key
|
||||||
|
FROM ff_column_config
|
||||||
|
""";
|
||||||
|
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await rd.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
var levelTags = rd.IsDBNull(5)
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: rd.GetString(5)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(t => t.ToLowerInvariant()).ToArray();
|
||||||
|
|
||||||
|
var cfg = new ColumnConfig
|
||||||
|
{
|
||||||
|
Id = rd.GetInt32(0),
|
||||||
|
Name = rd.GetString(1),
|
||||||
|
Enabled = rd.GetBoolean(2),
|
||||||
|
AdvisoryOnly = true,
|
||||||
|
FeedTag = rd.GetString(3).ToLowerInvariant(),
|
||||||
|
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
|
||||||
|
LevelTags = levelTags,
|
||||||
|
ScanSec = rd.GetDouble(6),
|
||||||
|
FeedFilterTauSec = rd.GetDouble(7),
|
||||||
|
FeedMoveThresholdPerMin = rd.GetDouble(8),
|
||||||
|
PressFilterTauSec = rd.GetDouble(9),
|
||||||
|
PressureBand = rd.GetDouble(10),
|
||||||
|
SettleSec = rd.GetDouble(11),
|
||||||
|
StaleSec = rd.GetDouble(12),
|
||||||
|
ProductKey = rd.GetString(13),
|
||||||
|
Streams = Array.Empty<StreamConfig>()
|
||||||
|
};
|
||||||
|
cols[cfg.Id] = (cfg, new List<StreamConfig>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec,
|
||||||
|
tau_sec, sp_min, sp_max, rate_up_per_min, rate_dn_per_min,
|
||||||
|
reflux_from_product, grade, level_tag
|
||||||
|
FROM ff_stream_config
|
||||||
|
ORDER BY id
|
||||||
|
""";
|
||||||
|
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await rd.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
int colId = rd.GetInt32(0);
|
||||||
|
if (!cols.TryGetValue(colId, out var entry)) continue;
|
||||||
|
entry.streams.Add(new StreamConfig
|
||||||
|
{
|
||||||
|
Key = rd.GetString(1),
|
||||||
|
FlowTag = rd.GetString(2).ToLowerInvariant(),
|
||||||
|
Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor,
|
||||||
|
LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14).ToLowerInvariant(),
|
||||||
|
TargetCoeff = rd.GetDouble(4),
|
||||||
|
ThetaUpSec = rd.GetDouble(5),
|
||||||
|
ThetaDnSec = rd.GetDouble(6),
|
||||||
|
TauSec = rd.GetDouble(7),
|
||||||
|
SpMin = rd.GetDouble(8),
|
||||||
|
SpMax = rd.GetDouble(9),
|
||||||
|
RateUpPerMin = rd.GetDouble(10),
|
||||||
|
RateDnPerMin = rd.GetDouble(11),
|
||||||
|
RefluxFromProduct = rd.GetBoolean(12),
|
||||||
|
Grade = Enum.TryParse<Confidence>(rd.GetString(13), true, out var g) ? g : Confidence.A
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols.Values.Select(e => e.cfg with { Streams = e.streams }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DbParameter P(DbCommand cmd, string name, object? val)
|
||||||
|
{
|
||||||
|
var p = cmd.CreateParameter();
|
||||||
|
p.ParameterName = name;
|
||||||
|
p.Value = val ?? DBNull.Value;
|
||||||
|
cmd.Parameters.Add(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = _ctx.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
int id = cfg.Id;
|
||||||
|
var levelTags = string.Join(',', cfg.LevelTags);
|
||||||
|
|
||||||
|
if (id == 0)
|
||||||
|
{
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO ff_column_config
|
||||||
|
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
|
||||||
|
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
|
||||||
|
pressure_band, settle_sec, stale_sec, product_key, advisory_only)
|
||||||
|
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE)
|
||||||
|
RETURNING id
|
||||||
|
""";
|
||||||
|
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant());
|
||||||
|
P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant());
|
||||||
|
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin);
|
||||||
|
P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec);
|
||||||
|
P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||||||
|
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.CommandText = """
|
||||||
|
UPDATE ff_column_config SET
|
||||||
|
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl,
|
||||||
|
scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt,
|
||||||
|
press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle,
|
||||||
|
stale_sec=@stale, product_key=@pk, advisory_only=TRUE
|
||||||
|
WHERE id=@id
|
||||||
|
""";
|
||||||
|
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
|
||||||
|
P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant());
|
||||||
|
P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec);
|
||||||
|
P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand);
|
||||||
|
P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스트림 원자적 교체
|
||||||
|
await using (var del = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
|
||||||
|
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
foreach (var s in cfg.Streams)
|
||||||
|
{
|
||||||
|
await using var ins = conn.CreateCommand();
|
||||||
|
ins.Transaction = tx;
|
||||||
|
ins.CommandText = """
|
||||||
|
INSERT INTO ff_stream_config
|
||||||
|
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
|
||||||
|
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag)
|
||||||
|
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag)
|
||||||
|
""";
|
||||||
|
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant());
|
||||||
|
P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec);
|
||||||
|
P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax);
|
||||||
|
P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
|
||||||
|
P(ins,"@grade",s.Grade.ToString());
|
||||||
|
await ins.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteColumnAsync(int columnId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = _ctx.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM ff_column_config WHERE id=@id";
|
||||||
|
P(cmd,"@id",columnId);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/Infrastructure/Control/FeedforwardEngine.cs
Normal file
163
src/Infrastructure/Control/FeedforwardEngine.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Control;
|
||||||
|
|
||||||
|
public sealed class StreamState
|
||||||
|
{
|
||||||
|
public DeadTimeBuffer Dead { get; } = new();
|
||||||
|
public FirstOrderLag Lag { get; } = new();
|
||||||
|
public RateLimiter Rate { get; } = new();
|
||||||
|
public double LastRec { get; set; } = double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ColumnState
|
||||||
|
{
|
||||||
|
public FirstOrderLag FeedFilter { get; } = new();
|
||||||
|
public FirstOrderLag PressFilter { get; } = new();
|
||||||
|
public Derivative FeedDeriv { get; } = new();
|
||||||
|
public double SettleTimerSec { get; set; }
|
||||||
|
public bool Initialized { get; set; }
|
||||||
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
||||||
|
|
||||||
|
public StreamState Stream(string key)
|
||||||
|
{
|
||||||
|
if (!Streams.TryGetValue(key, out var s)) { s = new StreamState(); Streams[key] = s; }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FeedforwardEngine
|
||||||
|
{
|
||||||
|
public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
|
||||||
|
{
|
||||||
|
var ts = cfg.ScanSec;
|
||||||
|
|
||||||
|
if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value))
|
||||||
|
return Hold(cfg, st, now, "FEED BAD");
|
||||||
|
|
||||||
|
var ff = st.FeedFilter.Step(pv.Feed.Value, cfg.FeedFilterTauSec, ts);
|
||||||
|
|
||||||
|
if (!st.Initialized) { SeedAll(cfg, pv, st, ff); st.Initialized = true; }
|
||||||
|
|
||||||
|
var dF = st.FeedDeriv.Update(ff, ts);
|
||||||
|
bool moving = cfg.FeedMoveThresholdPerMin > 0
|
||||||
|
&& Math.Abs(dF) * 60.0 > cfg.FeedMoveThresholdPerMin;
|
||||||
|
bool pUnstable = false;
|
||||||
|
if (pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value))
|
||||||
|
{
|
||||||
|
var pf = st.PressFilter.Step(pp.Value, cfg.PressFilterTauSec, ts);
|
||||||
|
pUnstable = Math.Abs(pp.Value - pf) > cfg.PressureBand;
|
||||||
|
}
|
||||||
|
if (moving || pUnstable) st.SettleTimerSec = cfg.SettleSec;
|
||||||
|
else st.SettleTimerSec = Math.Max(0.0, st.SettleTimerSec - ts);
|
||||||
|
bool transient = moving || pUnstable || st.SettleTimerSec > 0.0;
|
||||||
|
string treason = moving ? "FEED 이동"
|
||||||
|
: pUnstable ? "압력 불안정"
|
||||||
|
: st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";
|
||||||
|
|
||||||
|
var outs = new List<StreamAdvisory>(cfg.Streams.Count);
|
||||||
|
double? prodRec = null;
|
||||||
|
|
||||||
|
foreach (var s in cfg.Streams)
|
||||||
|
{
|
||||||
|
if (s.RefluxFromProduct) continue;
|
||||||
|
var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key));
|
||||||
|
if (s.Key == cfg.ProductKey) prodRec = rec;
|
||||||
|
outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key)));
|
||||||
|
}
|
||||||
|
foreach (var s in cfg.Streams)
|
||||||
|
{
|
||||||
|
if (!s.RefluxFromProduct) continue;
|
||||||
|
var stt = st.Stream(s.Key);
|
||||||
|
double? rec = null;
|
||||||
|
if (prodRec is double p)
|
||||||
|
{
|
||||||
|
var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax);
|
||||||
|
rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
|
||||||
|
}
|
||||||
|
outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt));
|
||||||
|
}
|
||||||
|
|
||||||
|
double? vloss = null, yield = null;
|
||||||
|
string mbState;
|
||||||
|
if (transient)
|
||||||
|
mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
|
||||||
|
else if (TryStreamPv(pv, "D", out var d) && TryStreamPv(pv, "P", out var pp2)
|
||||||
|
&& TryStreamPv(pv, "B", out var b) && ff > 1e-6)
|
||||||
|
{
|
||||||
|
vloss = ff - (d + pp2 + b);
|
||||||
|
yield = 100.0 * pp2 / ff;
|
||||||
|
mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
|
||||||
|
: vloss.Value < 0 ? "음의 손실(스팬 오류 의심)"
|
||||||
|
: "정상";
|
||||||
|
}
|
||||||
|
else mbState = "입력 부족";
|
||||||
|
|
||||||
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
||||||
|
transient, treason, ff, outs, vloss, yield, mbState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double? rec, string note) ComputeStream(
|
||||||
|
StreamConfig s, double ff, double dF, double ts, StreamState stt)
|
||||||
|
{
|
||||||
|
switch (s.Role)
|
||||||
|
{
|
||||||
|
case StreamRole.Commanded:
|
||||||
|
double theta = dF >= 0 ? s.ThetaUpSec : s.ThetaDnSec;
|
||||||
|
double fd = stt.Dead.Through(ff, theta, ts);
|
||||||
|
fd = stt.Lag.Step(fd, s.TauSec, ts);
|
||||||
|
double raw = Num.Clamp(s.TargetCoeff * fd, s.SpMin, s.SpMax);
|
||||||
|
double rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
|
||||||
|
return (rec, "");
|
||||||
|
case StreamRole.LevelDriven:
|
||||||
|
return (s.TargetCoeff * ff, "레벨 제어 구동 — 기대치(deadtime 미적용)");
|
||||||
|
default:
|
||||||
|
return (null, "모니터(SP 없음)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StreamAdvisory BuildAdvisory(
|
||||||
|
StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt)
|
||||||
|
{
|
||||||
|
double curPv = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : double.NaN;
|
||||||
|
int trend = rec is double r && Num.IsFinite(stt.LastRec)
|
||||||
|
? Math.Sign(r - stt.LastRec) : 0;
|
||||||
|
if (rec is double rr) stt.LastRec = rr;
|
||||||
|
double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null;
|
||||||
|
return new StreamAdvisory(s.Key, s.FlowTag, s.Role, curPv, rec, gap, trend,
|
||||||
|
!transient && s.Role != StreamRole.Monitor, s.Grade, s.LevelTag, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryStreamPv(PvSnapshot pv, string key, out double v)
|
||||||
|
{
|
||||||
|
v = double.NaN;
|
||||||
|
if (pv.Streams.TryGetValue(key, out var s) && s.Good && Num.IsFinite(s.Value)) { v = s.Value; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedAll(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff)
|
||||||
|
{
|
||||||
|
st.FeedDeriv.Update(ff, cfg.ScanSec);
|
||||||
|
foreach (var s in cfg.Streams)
|
||||||
|
{
|
||||||
|
var stt = st.Stream(s.Key);
|
||||||
|
double seed = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : ff * s.TargetCoeff;
|
||||||
|
stt.Lag.Seed(seed);
|
||||||
|
stt.Rate.Seed(seed);
|
||||||
|
stt.LastRec = seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdvisoryResult Hold(ColumnConfig cfg, ColumnState st, DateTime now, string reason)
|
||||||
|
{
|
||||||
|
var outs = cfg.Streams.Select(s =>
|
||||||
|
{
|
||||||
|
var stt = st.Stream(s.Key);
|
||||||
|
double? rec = Num.IsFinite(stt.LastRec) ? stt.LastRec : (double?)null;
|
||||||
|
return new StreamAdvisory(s.Key, s.FlowTag, s.Role, double.NaN, rec, null, 0,
|
||||||
|
false, s.Grade, s.LevelTag, $"홀드: {reason}");
|
||||||
|
}).ToList();
|
||||||
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, true, reason,
|
||||||
|
st.FeedFilter.Value, outs, null, null, $"홀드: {reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal file
104
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Control;
|
||||||
|
|
||||||
|
public sealed class FeedforwardSupervisor : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly FeedforwardEngine _engine;
|
||||||
|
private readonly IFeedforwardAdvisoryStore _store;
|
||||||
|
private readonly ILogger<FeedforwardSupervisor> _logger;
|
||||||
|
private readonly Dictionary<int, ColumnState> _states = new();
|
||||||
|
|
||||||
|
public FeedforwardSupervisor(
|
||||||
|
IServiceScopeFactory scopeFactory, FeedforwardEngine engine,
|
||||||
|
IFeedforwardAdvisoryStore store, ILogger<FeedforwardSupervisor> logger)
|
||||||
|
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; }
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
double minScan = 2.0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||||
|
|
||||||
|
var columns = await cfgStore.LoadAllAsync(ct);
|
||||||
|
var enabled = columns.Where(c => c.Enabled).ToList();
|
||||||
|
if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec);
|
||||||
|
|
||||||
|
foreach (var cfg in enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snap = await BuildSnapshotAsync(db, cfg);
|
||||||
|
var st = GetState(cfg.Id);
|
||||||
|
var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow);
|
||||||
|
_store.Set(res);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "FF tick 실패: column {Id}", cfg.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "FF supervisor 루프 오류");
|
||||||
|
}
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(minScan, 1.0, 10.0)), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ColumnState GetState(int id)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg)
|
||||||
|
{
|
||||||
|
string PvTag(string baseTag)
|
||||||
|
{
|
||||||
|
var t = baseTag.ToLowerInvariant();
|
||||||
|
return t.EndsWith(".pv") ? t : t + ".pv";
|
||||||
|
}
|
||||||
|
var feedTag = PvTag(cfg.FeedTag);
|
||||||
|
var tags = new List<string> { feedTag };
|
||||||
|
if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag));
|
||||||
|
tags.AddRange(cfg.LevelTags.Select(PvTag));
|
||||||
|
tags.AddRange(cfg.Streams.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!)));
|
||||||
|
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
|
||||||
|
|
||||||
|
var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))
|
||||||
|
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r);
|
||||||
|
|
||||||
|
TagSample Sample(string baseTag)
|
||||||
|
{
|
||||||
|
var tag = PvTag(baseTag);
|
||||||
|
if (rows.TryGetValue(tag.ToLowerInvariant(), out var r)
|
||||||
|
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
|
||||||
|
{
|
||||||
|
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
|
||||||
|
return new TagSample(tag, v, Good: fresh, r.Timestamp);
|
||||||
|
}
|
||||||
|
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
var feed = Sample(cfg.FeedTag);
|
||||||
|
var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag);
|
||||||
|
var levels = cfg.LevelTags.Select(Sample).ToList();
|
||||||
|
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
|
||||||
|
return new PvSnapshot(feed, press, levels, streams);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/Web/Controllers/FeedforwardController.cs
Normal file
96
src/Web/Controllers/FeedforwardController.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Feedforward;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Web.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/ff")]
|
||||||
|
public sealed class FeedforwardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IFeedforwardAdvisoryStore _store;
|
||||||
|
private readonly IFeedforwardConfigStore _config;
|
||||||
|
public FeedforwardController(
|
||||||
|
IFeedforwardAdvisoryStore store,
|
||||||
|
IFeedforwardConfigStore config)
|
||||||
|
{ _store = store; _config = config; }
|
||||||
|
|
||||||
|
// ── 설정 CRUD (Phase I: 인증 없음. 쓰기 API 추가 시 IKbAuthService 재도입) ──
|
||||||
|
[HttpGet("config")]
|
||||||
|
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cols = await _config.LoadAllAsync(ct);
|
||||||
|
return Ok(new { columns = cols.Select(MapConfig) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("config")]
|
||||||
|
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await _config.SaveColumnAsync(body, ct);
|
||||||
|
return Ok(new { success = true, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("config/{id:int}")]
|
||||||
|
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _config.DeleteColumnAsync(id, ct);
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object MapConfig(ColumnConfig c) => new
|
||||||
|
{
|
||||||
|
id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
|
||||||
|
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
|
||||||
|
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
|
||||||
|
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
|
||||||
|
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
|
||||||
|
streams = c.Streams.Select(s => new
|
||||||
|
{
|
||||||
|
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff,
|
||||||
|
thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
|
||||||
|
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
|
||||||
|
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Advisory (공개 읽기) ───────────────────────────────────────
|
||||||
|
[HttpGet("advisory")]
|
||||||
|
public IActionResult GetAll() => Ok(new
|
||||||
|
{
|
||||||
|
columns = _store.GetAll().Select(MapColumn)
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("advisory/{columnId:int}")]
|
||||||
|
public IActionResult Get(int columnId)
|
||||||
|
{
|
||||||
|
var r = _store.Get(columnId);
|
||||||
|
return r is null ? NotFound() : Ok(MapColumn(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object MapColumn(AdvisoryResult r) => new
|
||||||
|
{
|
||||||
|
columnId = r.ColumnId,
|
||||||
|
columnName = r.ColumnName,
|
||||||
|
computedAt = r.ComputedAt,
|
||||||
|
enabled = r.Enabled,
|
||||||
|
transient = r.Transient,
|
||||||
|
transientReason = r.TransientReason,
|
||||||
|
feedFiltered = r.FeedFiltered,
|
||||||
|
vLoss = r.VLoss,
|
||||||
|
yield = r.Yield,
|
||||||
|
massBalanceState = r.MassBalanceState,
|
||||||
|
streams = r.Streams.Select(s => new
|
||||||
|
{
|
||||||
|
key = s.Key,
|
||||||
|
flowTag = s.FlowTag,
|
||||||
|
role = s.Role.ToString(),
|
||||||
|
levelTag = s.LevelTag,
|
||||||
|
pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
|
||||||
|
recommendedSp = s.RecommendedSp,
|
||||||
|
gap = s.Gap,
|
||||||
|
trend = s.Trend,
|
||||||
|
valid = s.Valid,
|
||||||
|
grade = s.Grade.ToString(),
|
||||||
|
note = s.Note
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user