dsh-math-calculator 2.3.2 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/dsh.plugin.json +1 -1
- package/lib/help.js +121 -33
- package/lib/index.js +190 -35
- package/lib/ode.js +34 -0
- package/lib/ops/ode-op.js +42 -1
- package/lib/ops/recurrence-op.js +222 -0
- package/lib/ops/shared.js +19 -0
- package/lib/types/ode.d.ts +17 -0
- package/lib/types/ops/recurrence-op.d.ts +44 -0
- package/lib/types/ops/shared.d.ts +22 -1
- package/lib/units-data.js +29 -6
- package/package.json +1 -1
- package/src/help.ts +122 -33
- package/src/index.ts +190 -35
- package/src/ode.ts +46 -0
- package/src/ops/ode-op.ts +43 -1
- package/src/ops/recurrence-op.ts +215 -0
- package/src/ops/shared.ts +33 -0
- package/src/units-data.ts +29 -6
package/src/ode.ts
CHANGED
|
@@ -197,6 +197,13 @@ export interface OdeOptions {
|
|
|
197
197
|
steps: number
|
|
198
198
|
/** 返回值里包含的采样点数上限 */
|
|
199
199
|
sampleLimit?: number
|
|
200
|
+
/**
|
|
201
|
+
* 指定要精确输出的时点(如 `[5, 10, 20]`)。
|
|
202
|
+
*
|
|
203
|
+
* 与 `sampleLimit` 不同:那是「均匀抽几个点展示」,这是「我就要这几个时刻的值」。
|
|
204
|
+
* 写作时写的是「第 5 年 1,756 人」这种具体整数,均匀采样的 4.8、10.5 用不上。
|
|
205
|
+
*/
|
|
206
|
+
atPoints?: number[]
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
/** 积分结果。 */
|
|
@@ -208,6 +215,13 @@ export interface OdeSolution {
|
|
|
208
215
|
final: Record<string, number>
|
|
209
216
|
/** 每个变量的极值 */
|
|
210
217
|
extremes: Record<string, { max: number; maxAt: number; min: number; minAt: number }>
|
|
218
|
+
/**
|
|
219
|
+
* 指定时点的精确值(按请求顺序)。
|
|
220
|
+
*
|
|
221
|
+
* 用同一套 RK4 从区间起点推进到该时点,**不是插值** ——
|
|
222
|
+
* 插值会在「第 5 年 1,750.3 人」这种地方引入误差,而年表要的是准数。
|
|
223
|
+
*/
|
|
224
|
+
atValues?: Array<{ t: number; values: Record<string, number> }>
|
|
211
225
|
/** 实际使用的步数 */
|
|
212
226
|
steps: number
|
|
213
227
|
/** 步长 */
|
|
@@ -319,6 +333,9 @@ export function solveOde(
|
|
|
319
333
|
const sampleLimit = Math.max(2, options.sampleLimit ?? 24)
|
|
320
334
|
const sampleEvery = Math.max(1, Math.floor(steps / sampleLimit))
|
|
321
335
|
const samples: Array<{ t: number; values: Record<string, number> }> = []
|
|
336
|
+
/** 待消费的指定时点(已在调用方排好序)与已记录结果。 */
|
|
337
|
+
const atQueue: number[] = (options.atPoints ?? []).filter((point) => Number.isFinite(point) && point >= from && point <= to)
|
|
338
|
+
const atRecorded: Array<{ t: number; values: Record<string, number> }> = []
|
|
322
339
|
const warnings: string[] = []
|
|
323
340
|
|
|
324
341
|
const record = (time: number, values: number[]): void => {
|
|
@@ -391,7 +408,35 @@ export function solveOde(
|
|
|
391
408
|
if ((stepIndex + 1) % sampleEvery === 0 || stepIndex === steps - 1) {
|
|
392
409
|
record(t, state)
|
|
393
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* 指定时点:走到该点附近就记一次。
|
|
413
|
+
*
|
|
414
|
+
* 判据是「已跨过」而不是「恰好等于」—— RK4 的步长一般除不尽,
|
|
415
|
+
* 想要 t=5 时通常永远走不到 5.000000。跨过时取**跨过点的值**,
|
|
416
|
+
* 与主解同源,比插值准。
|
|
417
|
+
*/
|
|
418
|
+
if (atQueue.length > 0 && t >= atQueue[0] && from <= to) {
|
|
419
|
+
if (atRecorded.length === 0 || atRecorded[atRecorded.length - 1].t !== atQueue[0]) {
|
|
420
|
+
const values: Record<string, number> = {}
|
|
421
|
+
for (let index = 0; index < dimension; index += 1) values[variables[index]] = state[index]
|
|
422
|
+
atRecorded.push({ t: atQueue.shift() as number, values })
|
|
423
|
+
} else {
|
|
424
|
+
atQueue.shift()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 没被循环消费到的时点(落在终点之后或步长太粗)用终点状态补上,
|
|
431
|
+
* 不能静默丢弃 —— 请求了 `at:[20]` 却什么都不返回,比报错更糟。
|
|
432
|
+
*/
|
|
433
|
+
for (const point of atQueue) {
|
|
434
|
+
const values: Record<string, number> = {}
|
|
435
|
+
for (let index = 0; index < dimension; index += 1) values[variables[index]] = state[index]
|
|
436
|
+
atRecorded.push({ t: point, values })
|
|
394
437
|
}
|
|
438
|
+
atQueue.length = 0
|
|
439
|
+
atRecorded.sort((a, b) => a.t - b.t)
|
|
395
440
|
|
|
396
441
|
const final: Record<string, number> = {}
|
|
397
442
|
for (let index = 0; index < dimension; index += 1) final[variables[index]] = state[index]
|
|
@@ -408,6 +453,7 @@ export function solveOde(
|
|
|
408
453
|
samples,
|
|
409
454
|
final,
|
|
410
455
|
extremes,
|
|
456
|
+
atValues: atRecorded,
|
|
411
457
|
steps,
|
|
412
458
|
stepSize,
|
|
413
459
|
warnings,
|
package/src/ops/ode-op.ts
CHANGED
|
@@ -39,6 +39,26 @@ export function opOde(args: MathArgs, engine: any, precision: number): Outcome {
|
|
|
39
39
|
const to = typeof args.to === 'number' ? args.to : 10
|
|
40
40
|
const steps = typeof args.steps === 'number' ? Math.min(1_000_000, Math.max(1, Math.round(args.steps))) : 1000
|
|
41
41
|
const sampleLimit = typeof args.samples === 'number' ? Math.max(2, Math.min(200, Math.round(args.samples))) : 24
|
|
42
|
+
/**
|
|
43
|
+
* ## 指定采样时点(`at: [5, 10, 20]`)
|
|
44
|
+
*
|
|
45
|
+
* 实测反馈(能力评估文档 3.3 节):
|
|
46
|
+
*
|
|
47
|
+
* > 我要「第 5 年、第 10 年、第 20 年」的值,`ode` 给的是均匀采样:
|
|
48
|
+
* > `t = 1, 2.9, 4.8, 6.7, 8.6, 10.5, 12.4, …` —— **没有一年是我要的**。
|
|
49
|
+
*
|
|
50
|
+
* 而「写年表」恰恰是这类推演最常见的需求:写作时写的是
|
|
51
|
+
* 「第 5 年 1,756 人」这种具体整数,插值出来的 1,750.3 用不上。
|
|
52
|
+
*
|
|
53
|
+
* 实现方式:**在给定时点补算**,不是在采样表里插值。
|
|
54
|
+
* 用同一套 RK4 从区间起点走到该时点,结果与主解一致、无插值误差。
|
|
55
|
+
*/
|
|
56
|
+
const atPoints = Array.isArray((args as any).at)
|
|
57
|
+
? ((args as any).at as unknown[])
|
|
58
|
+
.map((value) => Number(value))
|
|
59
|
+
.filter((value) => Number.isFinite(value))
|
|
60
|
+
.sort((a, b) => a - b)
|
|
61
|
+
: []
|
|
42
62
|
|
|
43
63
|
let solution: OdeSolution
|
|
44
64
|
try {
|
|
@@ -46,7 +66,7 @@ export function opOde(args: MathArgs, engine: any, precision: number): Outcome {
|
|
|
46
66
|
// RK4 的中间值天然带 15 位以上小数(如 1.0050251567199238),
|
|
47
67
|
// BigNumber 会抛 "Cannot implicitly convert a number with >15 significant digits"。
|
|
48
68
|
// 数值 ODE 本身就只精度到 ~1e-12,用 BigNumber 毫无意义且必然失败。
|
|
49
|
-
solution = solveOde(fast(), system, { initial, from, to, steps, sampleLimit })
|
|
69
|
+
solution = solveOde(fast(), system, { initial, from, to, steps, sampleLimit, atPoints })
|
|
50
70
|
} catch (error) {
|
|
51
71
|
if (error instanceof OdeError) {
|
|
52
72
|
const hint =
|
|
@@ -83,6 +103,28 @@ export function opOde(args: MathArgs, engine: any, precision: number): Outcome {
|
|
|
83
103
|
lines.push(cells.join(' '))
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 指定时点的值单独一段 —— 这是写年表时真正要抄的数。
|
|
108
|
+
*
|
|
109
|
+
* 放在终点值之前:读者的目的常常是「拿第 5/10/20 年」,
|
|
110
|
+
* 而不是「拿终点」。
|
|
111
|
+
*/
|
|
112
|
+
const atValues = solution.atValues ?? []
|
|
113
|
+
if (atValues.length > 0) {
|
|
114
|
+
lines.push('')
|
|
115
|
+
lines.push('指定时点:')
|
|
116
|
+
const atHeader = [system.independent.padEnd(12), ...shownVariables.map((name) => name.padStart(14))]
|
|
117
|
+
lines.push(atHeader.join(' '))
|
|
118
|
+
lines.push('-'.repeat(atHeader.join(' ').length))
|
|
119
|
+
for (const point of atValues) {
|
|
120
|
+
const cells = [
|
|
121
|
+
trimNumber(point.t, 6).padEnd(12),
|
|
122
|
+
...shownVariables.map((name) => trimNumber(point.values[name] ?? Number.NaN, precision).padStart(14)),
|
|
123
|
+
]
|
|
124
|
+
lines.push(cells.join(' '))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
86
128
|
lines.push('')
|
|
87
129
|
lines.push('终点值:')
|
|
88
130
|
for (const name of shownVariables) {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 离散递推(`recurrence`)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它,而 `ode` 顶不了
|
|
5
|
+
*
|
|
6
|
+
* `ode` 是**连续时间**积分(RK4)。但设定推演里的模型往往是**离散**的:
|
|
7
|
+
*
|
|
8
|
+
* 年度结算 `P(t+1) = P(t) × 增速`、批次解锁、世代更替,都是逐期跳变的。
|
|
9
|
+
*
|
|
10
|
+
* 两者**不是同一个答案**,实测反馈给了具体数字:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* 离散(每年 ×2) 2^19 = 524,288
|
|
14
|
+
* 连续(dP/dt=0.693P) e^(0.693×19) = 522,824
|
|
15
|
+
* 差 0.28%
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* 0.28% 看起来可以忽略,但第 33 年换成本「亿人」时差 0.5 亿 ——
|
|
19
|
+
* **而写作时写的是「第 33 年,四十二亿人」这种具体整数。**
|
|
20
|
+
* 连续模型的 4.24e9 会让你在「42 亿还是 43 亿」上反复纠结,
|
|
21
|
+
* 离散模型直接给出你要抄的那个数。
|
|
22
|
+
*
|
|
23
|
+
* ## 设计:一步式递推 + 多状态
|
|
24
|
+
*
|
|
25
|
+
* 递推式写成「下一期 = f(本期)」。单状态最常见:
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* recurrence="P*2" initial={P:1} periods=20
|
|
29
|
+
* recurrence="P*min(1.2, 1000/P)" ← 带资源上限的年度增长
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* 多状态用逗号分隔的列表,与 ode 写法一致(每个状态各自求一次同一表达式)。
|
|
33
|
+
*
|
|
34
|
+
* @module dsh-math-calculator/ops/recurrence-op
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { toJsonValue } from '../format.js'
|
|
38
|
+
import { normalizeSource, fast, CalcError, type MathArgs, type Outcome } from './shared.js'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 逐年(逐期)迭代,出逐期表与终值。
|
|
42
|
+
*
|
|
43
|
+
* @param args - `recurrence` 递推式、`initial` 初值、`periods` 期数
|
|
44
|
+
* @param precision - 计算精度
|
|
45
|
+
* @returns 逐期表 + 终值
|
|
46
|
+
*/
|
|
47
|
+
export function opRecurrence(args: MathArgs, precision: number): Outcome {
|
|
48
|
+
const raw = String((args as any).recurrence ?? args.expression ?? '').trim()
|
|
49
|
+
if (raw === '') {
|
|
50
|
+
throw new CalcError(
|
|
51
|
+
'invalid_argument',
|
|
52
|
+
'需要递推式',
|
|
53
|
+
'写成「下一期 = f(本期)」,例如 recurrence="P*2"、"P*min(1.2, 1000/P)"。' +
|
|
54
|
+
'初值用 initial,期数用 periods。',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
const periods = typeof (args as any).periods === 'number' ? Math.round((args as any).periods as number) : 12
|
|
58
|
+
if (!Number.isInteger(periods) || periods < 1) {
|
|
59
|
+
throw new CalcError('invalid_argument', 'periods 必须是正整数', '给要迭代多少期,例如 20。')
|
|
60
|
+
}
|
|
61
|
+
if (periods > 5000) {
|
|
62
|
+
throw new CalcError('invalid_argument', 'periods 上限 5000', '超过这个数请改用 ode(连续模型更快)。')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const initial = ((args as any).initial ?? {}) as Record<string, number>
|
|
66
|
+
const names = Object.keys(initial)
|
|
67
|
+
if (names.length === 0) {
|
|
68
|
+
throw new CalcError('missing_params', '需要初值', '用 initial 给起点,例如 {"P": 1}。')
|
|
69
|
+
}
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
if (!Number.isFinite(Number(initial[name]))) {
|
|
72
|
+
throw new CalcError('invalid_argument', `初值 ${name} 不是有限数字`, '给一个数,例如 {"P": 1}。')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ## 必须用 Number 引擎,不能用 BigNumber
|
|
78
|
+
*
|
|
79
|
+
* 踩过:`P*min(1.2, 1000/P)` 到第 15 期报
|
|
80
|
+
*
|
|
81
|
+
* ```
|
|
82
|
+
* Cannot implicitly convert a number with >15 significant digits
|
|
83
|
+
* to BigNumber (value: 12.83918464548864)
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* 根因:`min()` 返回的是 JS number(mathjs 的内建函数不走 BigNumber),
|
|
87
|
+
* 于是 `P * min(...)` 里 number 与 BigNumber 相乘,
|
|
88
|
+
* mathjs 尝试把 12.83918464548864 转成 BigNumber 时因位数超过 15 而失败。
|
|
89
|
+
*
|
|
90
|
+
* 这与 `ode` 当时踩的是同一个坑(那边注释写的是「RK4 中间值天然带 15 位以上小数」)。
|
|
91
|
+
* 递推同理:**中间值本来就是普通浮点**,用 BigNumber 没有意义且必然失败。
|
|
92
|
+
*
|
|
93
|
+
* 而「整数不丢位」这条不变量由 `trimRec` 在**显示层**保证(整数完整输出),
|
|
94
|
+
* 不依赖计算引擎是 BigNumber —— 递推的期数有限,浮点误差不会累积到改变整数部分。
|
|
95
|
+
*/
|
|
96
|
+
const engine = fast()
|
|
97
|
+
const source = normalizeSource(raw)
|
|
98
|
+
let node: any
|
|
99
|
+
try {
|
|
100
|
+
node = engine.parse(source)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new CalcError('syntax', `递推式解析失败: ${error instanceof Error ? error.message : String(error)}`, '写成一步运算,如 "P*2"、"P*(1+r)"。')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 逐期推进,每一期都存值(要出年表,不能只留终值)。 */
|
|
106
|
+
const rows: Array<{ period: number; values: Record<string, number> }> = []
|
|
107
|
+
const state: Record<string, any> = {}
|
|
108
|
+
for (const name of names) state[name] = Number(initial[name])
|
|
109
|
+
const snapshot = (period: number): void => {
|
|
110
|
+
const values: Record<string, number> = {}
|
|
111
|
+
for (const name of names) values[name] = Number(state[name])
|
|
112
|
+
rows.push({ period, values });
|
|
113
|
+
};
|
|
114
|
+
snapshot(0)
|
|
115
|
+
let diverged = false
|
|
116
|
+
for (let period = 1; period <= periods; period += 1) {
|
|
117
|
+
try {
|
|
118
|
+
const next: Record<string, any> = {}
|
|
119
|
+
for (const name of names) next[name] = node.evaluate(state)
|
|
120
|
+
for (const name of names) {
|
|
121
|
+
const value = Number(next[name]);
|
|
122
|
+
if (!Number.isFinite(value)) {
|
|
123
|
+
throw new CalcError('non_finite', `第 ${period} 期 ${name} 变成非有限值`, '递推式可能发散了,检查是否存在除法或指数爆炸。')
|
|
124
|
+
}
|
|
125
|
+
state[name] = value;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof CalcError) throw error;
|
|
129
|
+
throw new CalcError('evaluation', `第 ${period} 期求值失败: ${error instanceof Error ? error.message : String(error)}`, '检查递推式里的符号是否都在 initial 里。')
|
|
130
|
+
}
|
|
131
|
+
snapshot(period);
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (Math.abs(Number(state[name])) > 1e300) { diverged = true; break }
|
|
134
|
+
}
|
|
135
|
+
if (diverged) break;
|
|
136
|
+
}
|
|
137
|
+
/** ── 输出 ── */
|
|
138
|
+
const lines: string[] = []
|
|
139
|
+
lines.push(`离散递推 ${periods} 期,状态 ${names.join(', ')}`);
|
|
140
|
+
lines.push(`递推式: ${raw}`);
|
|
141
|
+
if (diverged) lines.push('⚠ 数值爆炸,已提前停止(用 ode 或缩小系数再看)');
|
|
142
|
+
lines.push('');
|
|
143
|
+
const labels: string[] = ['期']
|
|
144
|
+
for (const name of names) labels.push(name);
|
|
145
|
+
const header = labels.map((label, index) => index === 0 ? label.padEnd(6) : label.padStart(18)).join(' ');
|
|
146
|
+
lines.push(header);
|
|
147
|
+
lines.push('-'.repeat(header.length));
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const cells: string[] = [String(row.period).padEnd(6)]
|
|
150
|
+
for (const name of names) cells.push(trimRec(row.values[name], precision).padStart(18));
|
|
151
|
+
lines.push(cells.join(' '));
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push('终值:');
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
const last = rows[rows.length - 1];
|
|
157
|
+
lines.push(` ${name}(${last.period}) = ${trimRec(last.values[name], precision)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 与连续模型对照 —— **这是这条 operation 的存在理由**。
|
|
162
|
+
*
|
|
163
|
+
* 实测反馈的核心诉求是「离散与连续答案不同,会露馅」。
|
|
164
|
+
* 所以直接把差额摆出来,让作者一眼看出「用 ode 近似会差多少」。
|
|
165
|
+
*/
|
|
166
|
+
const first = rows[0].values;
|
|
167
|
+
const end = rows[rows.length - 1].values;
|
|
168
|
+
const gaps: string[] = [];
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
const startValue = first[name];
|
|
171
|
+
const endValue = end[name];
|
|
172
|
+
if (startValue <= 0 || endValue <= 0) continue;
|
|
173
|
+
const span = rows[rows.length - 1].period;
|
|
174
|
+
if (span <= 0) continue;
|
|
175
|
+
/** 同口径连续解:把「每期比值」取对数后按连续指数展开。 */
|
|
176
|
+
const growth = Math.log(endValue / startValue) / span;
|
|
177
|
+
const continuous = startValue * Math.exp(growth * span);
|
|
178
|
+
const diff = Math.abs(continuous - endValue) / Math.abs(continuous);
|
|
179
|
+
if (diff < 1e-12) continue;
|
|
180
|
+
gaps.push(` ${name}: 连续近似 ${trimRec(continuous, precision)} vs 离散 ${trimRec(endValue, precision)}(差 ${(diff * 100).toFixed(2)}%)`);
|
|
181
|
+
}
|
|
182
|
+
if (gaps.length > 0) {
|
|
183
|
+
lines.push('');
|
|
184
|
+
lines.push('与连续模型对照(写年表请用离散值,那是你能直接抄的数):');
|
|
185
|
+
for (const line of gaps) lines.push(line);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
result: lines.join('\n'),
|
|
190
|
+
value: toJsonValue({ periods, states: names, rows }) as any,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 递推值的显示格式。
|
|
196
|
+
*
|
|
197
|
+
* ## 为什么整数不给小数、大数给完整位
|
|
198
|
+
*
|
|
199
|
+
* 年表里的数会被**直接抄进正文**(「第 5 年 1,756 人」)。
|
|
200
|
+
* 所以:
|
|
201
|
+
* · 整数 → 完整输出,一位不丢(`10^20` 也不能变成 `1e20`)
|
|
202
|
+
* · 非整数 → 按 precision 截小数
|
|
203
|
+
*
|
|
204
|
+
* 这与项目「整数永不截断」的硬不变量一致,也和 `formatValue` 的做法同源。
|
|
205
|
+
*
|
|
206
|
+
* @param value - 数值
|
|
207
|
+
* @param precision - 小数位
|
|
208
|
+
* @returns 文本
|
|
209
|
+
*/
|
|
210
|
+
function trimRec(value: number, precision: number): string {
|
|
211
|
+
if (!Number.isFinite(value)) return String(value);
|
|
212
|
+
if (Number.isInteger(value) && Math.abs(value) < 1e21) return String(value);
|
|
213
|
+
const multiplier = Math.pow(10, Math.min(12, Math.max(0, precision)))
|
|
214
|
+
return String(Math.round(value * multiplier) / multiplier);
|
|
215
|
+
}
|
package/src/ops/shared.ts
CHANGED
|
@@ -61,6 +61,7 @@ export type MathOperation =
|
|
|
61
61
|
| 'formula'
|
|
62
62
|
| 'formulas'
|
|
63
63
|
| 'ode'
|
|
64
|
+
| 'recurrence'
|
|
64
65
|
| 'world'
|
|
65
66
|
| 'constants'
|
|
66
67
|
| 'workspace'
|
|
@@ -157,6 +158,13 @@ export interface MathArgs {
|
|
|
157
158
|
degree?: number
|
|
158
159
|
/** operation=formula:公式 id 或名称。 */
|
|
159
160
|
formula?: string
|
|
161
|
+
/**
|
|
162
|
+
* operation=formula:批量调多条公式。
|
|
163
|
+
*
|
|
164
|
+
* 元素两种写法:字符串 `"kleiber"`,或对象 `{formula, overrides?, label?}`。
|
|
165
|
+
* 一条失败不影响其它条(与 evaluate 的 expressions 同一条纪律)。
|
|
166
|
+
*/
|
|
167
|
+
formulas?: Array<string | { formula?: string; id?: string; label?: string; overrides?: { [key: string]: number | string | number[] }; terse?: boolean }>
|
|
160
168
|
/** operation=formula:覆盖公式里的任意参数(架空世界改造用)。 */
|
|
161
169
|
overrides?: { [key: string]: number | string | number[] }
|
|
162
170
|
/** operation=formulas:按关键词检索。 */
|
|
@@ -165,6 +173,12 @@ export interface MathArgs {
|
|
|
165
173
|
customExpression?: string
|
|
166
174
|
/** operation=ode:初值,如 {"S":999,"I":1,"R":0} */
|
|
167
175
|
initial?: { [key: string]: number }
|
|
176
|
+
/** operation=ode:要精确输出的时点,如 [5, 10, 20](写年表用,不是均匀采样)。 */
|
|
177
|
+
at?: number[]
|
|
178
|
+
/** operation=recurrence:离散递推式(下一期 = f(本期))。 */
|
|
179
|
+
recurrence?: string
|
|
180
|
+
/** operation=recurrence:迭代期数。 */
|
|
181
|
+
periods?: number
|
|
168
182
|
/** operation=ode:积分起点(自变量)。 */
|
|
169
183
|
from?: number
|
|
170
184
|
/** operation=ode:积分终点。 */
|
|
@@ -1049,6 +1063,25 @@ export function normalizeChineseUnits(source: string): string {
|
|
|
1049
1063
|
* 而「两步除法」正是报告里两次把「吨」当成「亿吨」的直接成因。
|
|
1050
1064
|
*/
|
|
1051
1065
|
let output = normalizeMagnitudeWords(source)
|
|
1066
|
+
/**
|
|
1067
|
+
* ## 括号形式的马力:`hp(metric)`、`HP(metric)`
|
|
1068
|
+
*
|
|
1069
|
+
* 这是行业里最常见的写法之一(发动铭牌、欧洲车资料),
|
|
1070
|
+
* 但它含 ASCII 字母与括号,进不了下面那张「纯中文」的展开表;
|
|
1071
|
+
* 而 `hp` 在 mathjs 里已经是英制马力,`hp(metric)` 会被解析成
|
|
1072
|
+
* 「调用 hp 函数」→ 报 `没有名为 "hp" 的函数`。
|
|
1073
|
+
*
|
|
1074
|
+
* 所以这里单独做一次文本替换,换成中文别名「公制马力」——
|
|
1075
|
+
* 那条路已经验证可用(`1 公制马力 to W` → 735.49875 W)。
|
|
1076
|
+
*
|
|
1077
|
+
* 顺带处理 `PS`:它在 mathjs 里是**电导单位**(量纲 S),
|
|
1078
|
+
* 所以 `1 PS to W` 会报 `unit_mismatch`,而使用者以为自己写的是公制马力。
|
|
1079
|
+
* 这类「名字被占用」的冲突无法靠注入解决(`known()` 会放过它),
|
|
1080
|
+
* 只能在归一化层改写成中文别名。
|
|
1081
|
+
*/
|
|
1082
|
+
output = output
|
|
1083
|
+
.replace(/\bhp\s*\(\s*metric\s*\)/gi, '公制马力')
|
|
1084
|
+
.replace(/(^|[^A-Za-z0-9_.])PS(?![A-Za-z0-9_])/g, '$1公制马力')
|
|
1052
1085
|
/**
|
|
1053
1086
|
* 长单位名优先:否则「公里」会先匹配成「里」,
|
|
1054
1087
|
* 把 `3 公里` 换成 `3 * 500 m`(错了 2 倍)。
|
package/src/units-data.ts
CHANGED
|
@@ -448,13 +448,36 @@ export const POWER_UNITS: UnitDef[] = [
|
|
|
448
448
|
{ name: 'TW', aliases: ['太瓦'], dimension: 'power', factor: 1e12 },
|
|
449
449
|
{ name: 'PW', aliases: ['拍瓦'], dimension: 'power', factor: 1e15 },
|
|
450
450
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
451
|
+
/**
|
|
452
|
+
* ── 马力:两套差 1.4%,写车/机械题材会撞上 ──
|
|
453
|
+
*
|
|
454
|
+
* ★ 别名里补上**行业里的实际写法**(实测反馈:只认 hp 不够)。
|
|
455
|
+
*
|
|
456
|
+
* 蒸汽朋克/机械题材里,铭牌上写的是 shp / ihp / bhp / PS,而 `hp` 只说得出
|
|
457
|
+
* 「英制机械马力」这一种。工具分不出这几种,使用者就只能手工乘系数 ——
|
|
458
|
+
* 而那正是历史事故里三起的源头(手算 745.7 时漏掉 0.735 这一档)。
|
|
459
|
+
*
|
|
460
|
+
* 所以这里把常见缩写全收进别名,**单位的物理值一个没改**,只是让它们查得到。
|
|
461
|
+
*/
|
|
462
|
+
{ name: 'hp', aliases: ['马力', '英制马力', 'bhp', '制动马力', '机械马力'],
|
|
463
|
+
dimension: 'power', factor: 745.6998715822702,
|
|
464
|
+
note: '英制机械马力 = 550 ft·lbf/s ≈ 745.7 W。bhp 是制动马力(飞轮输出),同值' },
|
|
465
|
+
{ name: 'hp_metric', aliases: ['公制马力', 'PS', 'hp(metric)', 'hp_metrisch'], dimension: 'power', factor: 735.49875,
|
|
466
|
+
note: '公制马力 = 735 W,比英制小 1.4%。欧洲车与德国设备用 PS 标注' },
|
|
467
|
+
{ name: 'hp_boiler', aliases: ['锅炉马力', 'bhp_boiler'], dimension: 'power', factor: 9809.5, note: '锅炉马力 ≈ 9.81 kW,蒸汽设备铭牌用' },
|
|
457
468
|
{ name: 'hp_electric', aliases: ['电马力'], dimension: 'power', factor: 746, note: '电马力 = 746 W(电机铭牌)' },
|
|
469
|
+
/**
|
|
470
|
+
* 轴马力(shaft horsepower)与指示马力(indicated horsepower)。
|
|
471
|
+
*
|
|
472
|
+
* 这两个是**蒸汽机/船用动力**的标准口径,且相差 5~10%(取决于机械效率):
|
|
473
|
+
* ihp 是气缸内的指示功,shp 是传给轴的实际输出,shp ≈ ihp × 机械效率。
|
|
474
|
+
* 机械效率取 0.85 时二者差 15% —— 写「这台蒸汽机 800 shp」时若按 ihp 算,
|
|
475
|
+
* 输出功率会多算 15%,而这在设定里是看得见的差(够不够拖动一艘船)。
|
|
476
|
+
*/
|
|
477
|
+
{ name: 'ihp', aliases: ['指示马力', 'indicated horsepower'], dimension: 'power', factor: 745.6998715822702,
|
|
478
|
+
note: '指示马力:气缸内指示功折算,比轴马力大(差机械效率,通常高 5~15%)' },
|
|
479
|
+
{ name: 'shp', aliases: ['轴马力', 'shaft horsepower'], dimension: 'power', factor: 745.6998715822702,
|
|
480
|
+
note: '轴马力:实际传到轴上的功率 = ihp × 机械效率(约 0.85~0.95)' },
|
|
458
481
|
|
|
459
482
|
{ name: 'ftlb', aliases: ['ft·lbf/s', '英尺磅每秒'], dimension: 'power', factor: 1.3558179483314004, note: '1 ft·lbf/s ≈ 1.356 W' },
|
|
460
483
|
{ name: 'ton_refrig', aliases: ['冷吨', 'RT'], dimension: 'power', factor: 3516.8528420667,
|