// tools/convolution_calculator.dart import 'package:flutter/material.dart'; import '../symja.dart'; import 'tool_kit.dart'; /// 卷积(分布列相加):把 X、Y 两个分布列逐对组合,得到 X + Y 的分布列。 /// /// 行数可变,输入框在 State 里按行维护(每次 setState 生成一份新的 fields 给外壳)。 /// 概率全部交给 Symja:两两相乘、合并同取值时求和都是 Symja 算的精确分数, /// Dart 只在最后把"取值相同"的项归到一组(分组是记账,不是算数)。 class ConvolutionCalculatorPage extends StatefulWidget { const ConvolutionCalculatorPage({super.key}); @override State createState() => _ConvolutionCalculatorPageState(); } /// 一行输入:一个取值 + 一个概率。 class _Row { _Row(String name, int index, String v, String p) : value = ToolField('$name 取值 $index', hint: '如 0'), // 取值和概率并排一行 —— 它们本来就是一行的两个格子 prob = ToolField( '$name 概率 $index', hint: '如 1/2', rowWithPrevious: true, ) { value.controller.text = v; prob.controller.text = p; } final ToolField value; final ToolField prob; } class _ConvolutionCalculatorPageState extends State { // 只允许在末尾增删,行号(标签里的 1、2…)才不会错位。 final List<_Row> _x = []; final List<_Row> _y = []; @override void initState() { super.initState(); // 默认 X = (0, 1/2), (1, 1/2);Y = (0, 1/3), (1, 1/3), (2, 1/3)。 _x.add(_Row('X', 1, '0', '1/2')); _x.add(_Row('X', 2, '1', '1/2')); _y.add(_Row('Y', 1, '0', '1/3')); _y.add(_Row('Y', 2, '1', '1/3')); _y.add(_Row('Y', 3, '2', '1/3')); } void _addRow(String name, List<_Row> rows) { setState(() => rows.add(_Row(name, rows.length + 1, '', ''))); } void _removeRow(List<_Row> rows) { if (rows.length <= 1) return; setState(() => rows.removeLast()); } /// 校验某一列概率之和为 1。判等走 Symja(见调用处的注释)。 Future _requireSumOne( SymjaSession session, String name, String probs, ) async { final ok = await session.compute('Total[$probs] == 1'); if (ok.text == 'True') return; final sum = await session.compute('Total[$probs]'); throw Exception('$name 的概率之和必须为 1,当前为 ${sum.text}'); } List get _fields => [ for (final r in _x) ...[r.value, r.prob], for (final r in _y) ...[r.value, r.prob], ]; String _pairs(List<_Row> rows) => '{${rows.map((r) => '{${r.value.text}, ${r.prob.text}}').join(', ')}}'; String _probs(List<_Row> rows) => '{${rows.map((r) => r.prob.text).join(', ')}}'; void _validate(String name, List<_Row> rows) { for (var i = 0; i < rows.length; i++) { if (rows[i].value.text.isEmpty || rows[i].prob.text.isEmpty) { throw Exception('$name 的第 ${i + 1} 行还有空栏,请把取值和概率都填上'); } } } @override Widget build(BuildContext context) => ToolScaffold( title: '卷积分布列', fields: _fields, // 增删按钮贴着**自己那一组**:X 的按钮在 X 那组上面,Y 的在 Y 那组上面。 // Y 第一个输入框的下标 = X 的行数 × 2(每行两个框)。 beforeField: { 0: _rowButtons('X', _x), _x.length * 2: _rowButtons('Y', _y), }, compute: (session) async { _validate('X', _x); _validate('Y', _y); final xPairs = _pairs(_x); final yPairs = _pairs(_y); // 概率之和必须为 1:求和和**判等**都交给 Symja —— 概率填小数时 // `Total` 回的是 `0.9999999999999999` / `1.0` 这种,拿 Dart 字符串 // 去比 `1` 会把合法输入判成错的。 await _requireSumOne(session, 'X', _probs(_x)); await _requireSumOne(session, 'Y', _probs(_y)); // 卷积:逐对组合,先不合并(Symja 给的是一对对 {取值, 概率})。 final conv = await session.compute( 'With[{x=$xPairs, y=$yPairs}, ' 'Flatten[Table[{x[[i,1]]+y[[j,1]], x[[i,2]]*y[[j,2]]}, ' '{i,1,Length[x]}, {j,1,Length[y]}], 1]]', ); final pairs = parseSymjaList(conv.text); // 按取值原文分组(纯记账)。每组的概率仍交给 Symja 相加,保证是精确分数。 final order = []; final groups = >{}; for (final item in pairs) { if (item is! List || item.length < 2) continue; final v = '${item[0]}'; final p = '${item[1]}'; groups .putIfAbsent(v, () { order.add(v); return []; }) .add(p); } // 排序也交给 Symja:Sort 按数值大小排,比 Dart 的字符串排序可靠 // (字符串排序会把 10 排在 2 前面)。取值不多,单独问一次开销很小。 final sorted = order.isEmpty ? const [] : parseSymjaList( (await session.compute('Sort[{${order.join(', ')}}]')).text, ); final outputs = []; final groupSums = []; for (final v in sorted) { final key = '$v'; final ps = groups[key]; if (ps == null) continue; final total = await session.compute('Total[{${ps.join(', ')}}]'); final totalN = await session.compute( approxExpr( 'Rationalize[Total[{${ps.join(', ')}}]]', session.approximateDigits, ), ); groupSums.add(total.text); // 精确分数 + 小数:概率这种"看一眼量级"的值,两者都有用 outputs.add( ToolText('X + Y = $key', withApprox(total.text, totalN.text)), ); } final grand = await session.compute('Total[{${groupSums.join(', ')}}]'); final grandN = await session.compute( 'N[Rationalize[Total[{${groupSums.join(', ')}}]], ' '${session.approximateDigits}]', ); outputs.add(ToolText('概率总和', withApprox(grand.text, grandN.text))); return outputs; }, ); /// 一行「删除一行 / 添加一行」—— 输入框自己的标签已经写明了是 X 还是 Y, /// 不用再来一行标题重复。 Widget _rowButtons(String name, List<_Row> rows) => Padding( padding: const EdgeInsets.only(top: 8), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: () => _removeRow(rows), icon: const Icon(Icons.remove, size: 18), label: const Text('删除一行'), ), TextButton.icon( onPressed: () => _addRow(name, rows), icon: const Icon(Icons.add, size: 18), label: const Text('添加一行'), ), ], ), ); }