feat(resample): 实验性功能新增「重采样」(位于「自由标记」上方)

- Lane::ResampleItem + resample/resampleItems/resamplePanel/resampleScroll,
  并在 LaneCarry / SplitLane / PruneLaneSignalItems / RemapAfterSignalInsert|Removal 同步
- 面板:可滚动分组列表(信号色条目栏 + 复选框 + 展开三角),展开后 4 行:
  最大/最小数据间隔(只读,勾选时一次性缓存,绘制不扫描采样)+ 重采样间隔/频率(输入)
- 输入框联动:改间隔同步频率,改频率同步间隔(输入单位分别为秒 / Hz)
- 「构建为新信号」:DataSet::AppendGroupAxis 追加新时间轴 + InsertDerivedSignal,
  采样值先按公式/系数/绝对值/镜像/Y偏移变换再等间隔线性插值,时间轴偏移不烘值
- 新信号名 <原信号> (重采样 <自适应单位频率>);Utf8Util::FormatFrequency 为唯一实现
- 非单调轴 / 点太少 / 间隔过大 / 点数超 2000 万 ⇒ 拒绝构建并给出自绘提示
- 顺带加入 FormulaEval(公式编译/求值,供 ApplyDrawTransform 与后续 UI 使用)
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-02 20:41:26 +08:00
1 parent 30e9d65c30
commit 1e1c2432ae
19 files changed
+1979 -56

No files matched your search

+98 -1
View File
@@ -13,6 +13,7 @@
#include "DataLoader.hpp"
#include "DataTable.hpp"
#include "FormulaEval.hpp"
namespace jdi
{
@@ -230,6 +231,18 @@ namespace jdi
float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */
/**
* @brief 「公式映射」表达式(`Y = <formula>`,自变量写作 `f(t)`;空 = 不映射)。
*
* 结算顺序为 **公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移 → 时间轴偏移**,
* 见 `ApplyDrawTransform`。表达式由 `Utilities/FormulaEval` 编译(带缓存),
* 非法表达式的取值原样返回采样值(不会让波形消失)。
*/
std::wstring formula;
/** @brief 是否启用了「公式映射」。 */
bool HasFormula() const { return !formula.empty(); }
/** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */
bool Mirrored() const { return mirrorVertical || mirrorHorizontal; }
@@ -275,6 +288,12 @@ namespace jdi
*/
inline double ApplyDrawTransform(const SignalStyle& style, double value)
{
// 「公式映射」最先结算(用户 2026-11-02 明确要求:公式 → 系数 → 偏移)。
// ⚠️ 表达式编译结果按样式对象缓存,**不会**逐采样点重新解析。
if (!style.formula.empty())
{
value = EvaluateFormula(style.formula, value);
}
if (style.gain != 1.0f)
{
value *= static_cast<double>(style.gain);
@@ -325,7 +344,9 @@ namespace jdi
*/
inline bool DrawTransformOrderPreserving(const SignalStyle& style)
{
return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical;
// ⚠️ 「公式映射」不保证保序(`x * x` 在负半轴递减),因此一律按非保序处理,
// 让包络绘制回退到逐采样点变换(粗粒度分组层只存 min/max,非保序会算错)。
return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical && !style.HasFormula();
}
/**
@@ -569,6 +590,19 @@ namespace jdi
*/
bool AddGroup(SignalGroup&& group, int& groupIndex);
/**
* @brief 在某个分组末尾追加一条**新时间轴**(「重采样」派生的信号使用它)。
*
* 重采样产生的是**新的时间刻度**,无法复用源信号的时间轴;追加后该分组的
* `axes` 多一条,返回它的下标,供随后 `InsertDerivedSignal` 的 `axisIndex` 使用。
*
* @param [IN] groupIndex 目标分组下标。
* @param [IN,OUT] axis 时间轴(内部缓冲会被移走)。
* @param [OUT] axisIndex 新时间轴在分组内的下标。
* @return 追加成功返回 true(轴为空/分组越界时返回 false)。
*/
bool AppendGroupAxis(int groupIndex, TimeAxis&& axis, std::size_t& axisIndex);
/**
* @brief 在指定分组的指定信号**下方**插入一条新信号(派生信号)。
*
@@ -831,6 +865,33 @@ namespace jdi
double time = 0.0; /**< 显示时刻(秒)。 */
};
/**
* @brief 「重采样」中某个信号自己的参数。
*
* 该功能把**非固定频率**采样的信号规整成固定间隔采样(超分 / 降采样),
* 再把结果派生为一条新信号。参数按信号保存,同一网格内不同信号可以用不同间隔。
*
* ⚠️ `interval` 只在**首次勾选**(`initialized == false`)时初始化为该信号的最小
* 数据间隔;之后用户手动输入的间隔不会被重新初始化覆盖。
*/
struct ResampleItem
{
SignalRef ref; /**< 目标信号。 */
double interval = 0.0; /**< 重采样间隔(秒,> 0)。 */
/** @brief `interval` 是否已经按「该信号最小数据间隔」初始化过。 */
bool initialized = false;
/**
* @brief 该信号的**最大 / 最小**数据间隔(相邻有效采样点时间差的极值)。
*
* ⚠️ 在勾选该信号时由 `PlotView` 一次性算好并缓存(千万级信号逐帧重算会卡死),
* 面板绘制只读这两个字段。数据加载后不再变化,因此无需失效。
*/
double maxInterval = 0.0;
double minInterval = 0.0;
/** @brief 参数行是否被**收起**(只保留信号色叠底的条目栏)。 */
bool collapsed = false;
};
std::wstring name; /**< 网格名称。 */
std::vector<SignalRef> signals; /**< 该网格包含的信号(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
@@ -1102,6 +1163,42 @@ namespace jdi
*/
float edgeCountScroll = 0.0f;
/**
* @brief 「实验性功能 → 重采样」开关(功能总开关)。
*
* 开启后本网格出现浮动面板:以网格内每个信号为条目(信号色叠底 + 复选框 +
* 展开/收起),展开后显示「最大数据间隔 / 最小数据间隔」两行统计与
* 「重采样间隔 / 重采样频率」两行**联动输入框**;底部「构建为新信号」按当前参数
* 把勾选的信号重采样成固定频率并插到源信号下方。
*
* 与其它实验性功能一样属于**网格级**状态,重建网格时按信号继承(只由原网格首个信号持有)。
*/
bool resample = false;
/** @brief 参与重采样的信号及其参数(只包含被勾选的信号)。 */
std::vector<ResampleItem> resampleItems;
/** 取某个信号的重采样参数(未勾选时返回空指针)。 */
const ResampleItem* FindResampleItem(const SignalRef& ref) const;
/** 取得或创建某信号的重采样参数(用于勾选)。 */
ResampleItem& EnsureResampleItem(const SignalRef& ref);
/** 取消某信号的重采样勾选。 */
void RemoveResampleItem(const SignalRef& ref);
/** 取某信号的重采样勾选状态。 */
bool ResampleEnabled(const SignalRef& ref) const
{
return FindResampleItem(ref) != nullptr;
}
/** 重采样浮动面板的 UI 状态(展开/折起 + 位置)。 */
LanePanelState resamplePanel;
/** @brief 「重采样」面板内容的滚动偏移(客户区像素,0 = 顶部)。 */
float resampleScroll = 0.0f;
/**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
+17
View File
@@ -87,6 +87,7 @@ namespace jdi
prune(lane.edgeCenterItems);
prune(lane.weakPeakItems);
prune(lane.freeMarkerItems);
prune(lane.resampleItems);
lane.freeMarkerCollapsedGroups.erase(
std::remove_if(lane.freeMarkerCollapsedGroups.begin(), lane.freeMarkerCollapsedGroups.end(),
[&inLane](const SignalRef& ref) { return !inLane(ref); }),
@@ -140,6 +141,11 @@ namespace jdi
bool freeMarkerShowDelta = false;
bool freeMarkerShowEnum = false;
std::vector<SignalRef> freeMarkerCollapsedGroups;
/** @brief 「重采样」开关与逐信号参数(详见 `Lane::resample`)。 */
bool resample = false;
std::vector<Lane::ResampleItem> resampleItems;
LanePanelState resamplePanel;
float resampleScroll = 0.0f;
/** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */
float meanFilterScroll = 0.0f;
/** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */
@@ -199,6 +205,10 @@ namespace jdi
carry.freeMarkerShowDelta = lane.freeMarkerShowDelta;
carry.freeMarkerShowEnum = lane.freeMarkerShowEnum;
carry.freeMarkerCollapsedGroups = lane.freeMarkerCollapsedGroups;
carry.resample = lane.resample;
carry.resampleItems = lane.resampleItems;
carry.resamplePanel = lane.resamplePanel;
carry.resampleScroll = lane.resampleScroll;
for (std::size_t signalIndex = 0; signalIndex < lane.signals.size(); ++signalIndex)
{
LaneCarry entry = carry;
@@ -271,6 +281,13 @@ namespace jdi
lane.freeMarkerShowEnum = carry.freeMarkerShowEnum;
lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups;
lane.meanFilterScroll = carry.meanFilterScroll;
lane.resample = carry.resample;
lane.resampleItems = carry.resampleItems;
lane.resamplePanel.open = carry.resamplePanel.open;
lane.resamplePanel.collapsed = carry.resamplePanel.collapsed;
lane.resamplePanel.x = carry.resamplePanel.x;
lane.resamplePanel.y = carry.resamplePanel.y;
lane.resampleScroll = carry.resampleScroll;
};
// 收集全部可见信号引用。
+58
View File
@@ -0,0 +1,58 @@
#ifndef __FORMULA_EVAL_HPP__
#define __FORMULA_EVAL_HPP__
// 「公式映射」的表达式编译与求值(信号样式区的 `Y = …` 输入框)。
//
// 语法(刻意保持极小,避免引入脚本引擎与额外的体积):
// expr := term (('+' | '-') term)*
// term := unary (('*' | '/') unary)*
// unary := ('+' | '-') unary | primary
// primary := number | '(' expr ')' | variable
// number := 十进制小数(支持 e/E 指数)
// variable:= `X` / `x` / `f` / `f(t)` —— 都表示"当前采样点的值"
// 另外支持常量 `pi`。
//
// ⚠️ 求值走 `thread_local` 的单条缓存(按 `std::wstring` 的 data 指针 + 内容判定):
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
#include <string>
namespace jdi
{
/**
* @brief 校验公式文本是否可以解析。
*
* 空文本是合法输入(表示"不做映射")。
*
* @param [IN] text 公式文本(不含 `Y =` 前缀)。
* @param [OUT] error 解析失败时的原因(成功时清空),允许为空。
* @return 语法合法返回 true。
*/
bool ValidateFormula(const std::wstring& text, std::wstring* error);
/**
* @brief 计算公式在给定采样值处的取值。
*
* 文本为空或无法解析时**原样返回** `x`(保证绘制不会因为非法公式而消失)。
* 计算结果非有限(如 1/0)时返回 NaN(保持断线语义)。
*
* @param [IN] text 公式文本。
* @param [IN] x 采样值(公式中的 `f(t)` / `X`)。
* @return 映射后的值。
*/
double EvaluateFormula(const std::wstring& text, double x);
/**
* @brief 把用户输入里的自变量写法统一成 `f(t)`(保存前调用)。
*
* 规则:把独立的 `X` / `x` / `f` 记号改写为 `f(t)`;`f(t)` 原样保留。
* 只做记号级替换(不会动标识符中间或数字里的字符)。
*
* @param [IN] text 用户输入的公式。
* @return 归一化后的文本。
*/
std::wstring NormalizeFormulaText(const std::wstring& text);
} // namespace jdi
#endif // __FORMULA_EVAL_HPP__
+10
View File
@@ -121,6 +121,16 @@ namespace jdi
*/
std::wstring FormatDurationSeconds(double seconds);
/**
* @brief 把频率格式化为带自适应单位的文本(Hz / kHz / MHz / GHz,保留小数)。
*
* 用于「重采样」面板与派生信号名称(`<原信号> (重采样 1.2 kHz)`)。
*
* @param [IN] hertz 频率(Hz)。
* @return 形如 "1.2 kHz" 的文本;非法值返回 "—"。
*/
std::wstring FormatFrequency(double hertz);
/**
* @brief 计算“美观”的坐标轴刻度步长(1/2/5 乘以 10 的整数次幂)。
* @param [IN] range 坐标轴数值跨度,必须为正数。