homlab-polynomial 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Homology Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: homlab-polynomial
3
+ Version: 0.1.0
4
+ Summary: Collect indexed C terms in polynomials with SymPy.
5
+ Author: GGN_2015
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: sympy>=1.10
11
+ Dynamic: license-file
12
+
13
+ # homlab_polynomial
14
+
15
+ `homlab_polynomial` 提供一个 Python 编程入口,用 SymPy 将包含普通变量 `A` 和三下标变量
16
+ `C[x, y, z]` 的字符串表达式整理成按不同 `C[x, y, z]` 分组的形式。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ pip install .
22
+ ```
23
+
24
+ ## 使用
25
+
26
+ ```python
27
+ from homlab_polynomial import organize_polynomial
28
+
29
+ expr = "A*C[1, 2, 3] + 2*C[1,2,3] + A^2*C[0,0,0]"
30
+ print(organize_polynomial(expr))
31
+ ```
32
+
33
+ 输出:
34
+
35
+ ```text
36
+ C[0, 0, 0]*(A^2) + C[1, 2, 3]*(A + 2)
37
+ ```
38
+
39
+ 负指数也支持:
40
+
41
+ ```python
42
+ organize_polynomial("A^-10*C[0,0,0] + 2*C[0,0,0]/A")
43
+ ```
44
+
45
+ 输出:
46
+
47
+ ```text
48
+ C[0, 0, 0]*(2*A^(-1) + A^(-10))
49
+ ```
50
+
51
+ 说明:
52
+
53
+ - `C[x, y, z]` 的三个下标必须是整数,可以为负数。
54
+ - 输入支持用 `^` 表示指数运算,也支持 Python/SymPy 风格的 `**`。
55
+ - 默认输出也使用 `^`。如果需要 `**`,可以传入 `output_power_operator="**"`。
56
+ - 系数支持有限 Laurent 多项式,因此 `A^-1`、`1/A`、`A^-10` 都可以使用。
57
+ - `C[x, y, z]` 项不会带外部负号;负号会保留在括号中的系数多项式里。
58
+ - 不含 `C` 的纯 `A` 项会作为最后的余项保留。
59
+
60
+ 如果需要继续做 SymPy 计算,可以使用:
61
+
62
+ ```python
63
+ from homlab_polynomial import organize_polynomial_expr
64
+
65
+ sympy_expr = organize_polynomial_expr("A*C[1,2,3] + C[1,2,3]")
66
+ ```
67
+
68
+ ## 验算
69
+
70
+ `organize_polynomial()` 默认会在每次整理后进行随机代入验算。它会给 `A` 和所有出现过的
71
+ `C[x, y, z]` 赋非零有理数,检查整理前后的表达式是否一致。
72
+
73
+ 也可以直接调用验算函数:
74
+
75
+ ```python
76
+ from homlab_polynomial import verify_simplification
77
+
78
+ verify_simplification(
79
+ "A*C[1,2,3] + C[1,2,3]",
80
+ "C[1, 2, 3]*(A + 1)",
81
+ )
82
+ ```
@@ -0,0 +1,70 @@
1
+ # homlab_polynomial
2
+
3
+ `homlab_polynomial` 提供一个 Python 编程入口,用 SymPy 将包含普通变量 `A` 和三下标变量
4
+ `C[x, y, z]` 的字符串表达式整理成按不同 `C[x, y, z]` 分组的形式。
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ pip install .
10
+ ```
11
+
12
+ ## 使用
13
+
14
+ ```python
15
+ from homlab_polynomial import organize_polynomial
16
+
17
+ expr = "A*C[1, 2, 3] + 2*C[1,2,3] + A^2*C[0,0,0]"
18
+ print(organize_polynomial(expr))
19
+ ```
20
+
21
+ 输出:
22
+
23
+ ```text
24
+ C[0, 0, 0]*(A^2) + C[1, 2, 3]*(A + 2)
25
+ ```
26
+
27
+ 负指数也支持:
28
+
29
+ ```python
30
+ organize_polynomial("A^-10*C[0,0,0] + 2*C[0,0,0]/A")
31
+ ```
32
+
33
+ 输出:
34
+
35
+ ```text
36
+ C[0, 0, 0]*(2*A^(-1) + A^(-10))
37
+ ```
38
+
39
+ 说明:
40
+
41
+ - `C[x, y, z]` 的三个下标必须是整数,可以为负数。
42
+ - 输入支持用 `^` 表示指数运算,也支持 Python/SymPy 风格的 `**`。
43
+ - 默认输出也使用 `^`。如果需要 `**`,可以传入 `output_power_operator="**"`。
44
+ - 系数支持有限 Laurent 多项式,因此 `A^-1`、`1/A`、`A^-10` 都可以使用。
45
+ - `C[x, y, z]` 项不会带外部负号;负号会保留在括号中的系数多项式里。
46
+ - 不含 `C` 的纯 `A` 项会作为最后的余项保留。
47
+
48
+ 如果需要继续做 SymPy 计算,可以使用:
49
+
50
+ ```python
51
+ from homlab_polynomial import organize_polynomial_expr
52
+
53
+ sympy_expr = organize_polynomial_expr("A*C[1,2,3] + C[1,2,3]")
54
+ ```
55
+
56
+ ## 验算
57
+
58
+ `organize_polynomial()` 默认会在每次整理后进行随机代入验算。它会给 `A` 和所有出现过的
59
+ `C[x, y, z]` 赋非零有理数,检查整理前后的表达式是否一致。
60
+
61
+ 也可以直接调用验算函数:
62
+
63
+ ```python
64
+ from homlab_polynomial import verify_simplification
65
+
66
+ verify_simplification(
67
+ "A*C[1,2,3] + C[1,2,3]",
68
+ "C[1, 2, 3]*(A + 1)",
69
+ )
70
+ ```
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "homlab-polynomial"
7
+ version = "0.1.0"
8
+ description = "Collect indexed C terms in polynomials with SymPy."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "GGN_2015" }
14
+ ]
15
+ dependencies = [
16
+ "sympy>=1.10"
17
+ ]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Public API for homlab_polynomial."""
2
+
3
+ from .core import (
4
+ PolynomialParseError,
5
+ PolynomialVerificationError,
6
+ organize_polynomial,
7
+ organize_polynomial_expr,
8
+ verify_simplification,
9
+ )
10
+
11
+ __all__ = [
12
+ "PolynomialParseError",
13
+ "PolynomialVerificationError",
14
+ "organize_polynomial",
15
+ "organize_polynomial_expr",
16
+ "verify_simplification",
17
+ ]
@@ -0,0 +1,515 @@
1
+ """Polynomial collection utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from random import Random
7
+ import re
8
+ from typing import Literal, Optional
9
+
10
+ import sympy as sp
11
+ from sympy.parsing.sympy_parser import (
12
+ convert_xor,
13
+ implicit_multiplication_application,
14
+ parse_expr,
15
+ standard_transformations,
16
+ )
17
+
18
+
19
+ Index = tuple[int, int, int]
20
+ PowerOperator = Literal["^", "**"]
21
+
22
+ _C_PATTERN = re.compile(
23
+ r"(?<![A-Za-z_])C\s*\[\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*\]"
24
+ )
25
+ _TOKEN_PREFIX = "_homlab_C_"
26
+ _TRANSFORMATIONS = standard_transformations + (
27
+ convert_xor,
28
+ implicit_multiplication_application,
29
+ )
30
+ _SAFE_GLOBALS = {
31
+ "Add": sp.Add,
32
+ "Float": sp.Float,
33
+ "Integer": sp.Integer,
34
+ "Mul": sp.Mul,
35
+ "Pow": sp.Pow,
36
+ "Rational": sp.Rational,
37
+ "Symbol": sp.Symbol,
38
+ }
39
+
40
+
41
+ class PolynomialParseError(ValueError):
42
+ """Raised when a polynomial string cannot be parsed or collected."""
43
+
44
+
45
+ class PolynomialVerificationError(ValueError):
46
+ """Raised when randomized verification finds a mismatch."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _ParsedPolynomial:
51
+ expr: sp.Expr
52
+ a_symbol: sp.Symbol
53
+ c_symbols: dict[sp.Symbol, Index]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class _CollectedTerm:
58
+ c_powers: tuple[tuple[Index, int], ...]
59
+ coefficient: sp.Expr
60
+
61
+
62
+ def organize_polynomial(
63
+ expression: str,
64
+ *,
65
+ output_power_operator: PowerOperator = "^",
66
+ verify: bool = True,
67
+ verification_trials: int = 5,
68
+ random_seed: Optional[int] = None,
69
+ ) -> str:
70
+ """Collect a polynomial by indexed ``C[x, y, z]`` terms.
71
+
72
+ Parameters
73
+ ----------
74
+ expression:
75
+ Polynomial string using ``A`` and indexed ``C[x, y, z]`` variables.
76
+ The indices must be integers. Both ``^`` and ``**`` are accepted for
77
+ powers.
78
+ output_power_operator:
79
+ Power operator used in the returned string. The default is ``^`` to
80
+ match the input convention.
81
+ verify:
82
+ Whether to run randomized substitution checks before returning.
83
+ verification_trials:
84
+ Number of randomized substitution checks to run when ``verify`` is
85
+ true.
86
+ random_seed:
87
+ Optional seed for reproducible verification assignments.
88
+
89
+ Returns
90
+ -------
91
+ str
92
+ A string in the form ``C[x, y, z]*(polynomial in A) + ...``. If the
93
+ expression contains terms without any ``C`` factor, they are kept as a
94
+ final polynomial in ``A``.
95
+ """
96
+
97
+ if output_power_operator not in {"^", "**"}:
98
+ raise ValueError("output_power_operator must be '^' or '**'.")
99
+
100
+ parsed = _parse_polynomial(expression)
101
+ terms = _collect_terms(parsed)
102
+ result = _format_terms(terms, output_power_operator)
103
+ if verify:
104
+ verify_simplification(
105
+ expression,
106
+ result,
107
+ trials=verification_trials,
108
+ random_seed=random_seed,
109
+ )
110
+ return result
111
+
112
+
113
+ def organize_polynomial_expr(
114
+ expression: str,
115
+ *,
116
+ verify: bool = True,
117
+ verification_trials: int = 5,
118
+ random_seed: Optional[int] = None,
119
+ ) -> sp.Expr:
120
+ """Return the collected result as a SymPy expression.
121
+
122
+ This is useful when callers want to continue symbolic computation after
123
+ collection. The string API is usually more convenient for display because
124
+ it preserves the ``C[x, y, z]`` notation.
125
+ """
126
+
127
+ parsed = _parse_polynomial(expression)
128
+ terms = _collect_terms(parsed)
129
+ result = _terms_to_expr(terms)
130
+ if verify:
131
+ _verify_parsed_against_expr(
132
+ parsed,
133
+ result,
134
+ trials=verification_trials,
135
+ random_seed=random_seed,
136
+ )
137
+ return result
138
+
139
+
140
+ def verify_simplification(
141
+ original_expression: str,
142
+ simplified_expression: str,
143
+ *,
144
+ trials: int = 5,
145
+ random_seed: Optional[int] = None,
146
+ ) -> bool:
147
+ """Verify two expressions by randomized exact substitutions.
148
+
149
+ The function assigns the same random non-zero rational values to ``A`` and
150
+ every indexed ``C[x, y, z]`` appearing on either side, then checks that the
151
+ two expressions evaluate to the same value. It returns ``True`` when all
152
+ trials pass and raises ``PolynomialVerificationError`` on the first
153
+ mismatch.
154
+ """
155
+
156
+ original = _parse_polynomial(original_expression)
157
+ simplified = _parse_polynomial(simplified_expression)
158
+ _verify_parsed_pair(
159
+ original,
160
+ simplified,
161
+ trials=trials,
162
+ random_seed=random_seed,
163
+ )
164
+ return True
165
+
166
+
167
+ def _terms_to_expr(terms: list[_CollectedTerm]) -> sp.Expr:
168
+ c_base = sp.IndexedBase("C")
169
+ expr_terms: list[sp.Expr] = []
170
+ for term in terms:
171
+ if not term.c_powers:
172
+ expr_terms.append(term.coefficient)
173
+ continue
174
+
175
+ c_factor = _c_factor_expr(term.c_powers, c_base)
176
+ expr_terms.append(sp.Mul(c_factor, term.coefficient, evaluate=False))
177
+
178
+ if not expr_terms:
179
+ return sp.Integer(0)
180
+ return sp.Add(*expr_terms, evaluate=False)
181
+
182
+
183
+ def _verify_parsed_against_expr(
184
+ original: _ParsedPolynomial,
185
+ simplified_expr: sp.Expr,
186
+ *,
187
+ trials: int,
188
+ random_seed: Optional[int],
189
+ ) -> None:
190
+ simplified = _parse_polynomial(_sympy_expr_to_c_string(simplified_expr))
191
+ _verify_parsed_pair(
192
+ original,
193
+ simplified,
194
+ trials=trials,
195
+ random_seed=random_seed,
196
+ )
197
+
198
+
199
+ def _verify_parsed_pair(
200
+ original: _ParsedPolynomial,
201
+ simplified: _ParsedPolynomial,
202
+ *,
203
+ trials: int,
204
+ random_seed: Optional[int],
205
+ ) -> None:
206
+ if not isinstance(trials, int) or trials < 1:
207
+ raise ValueError("trials must be a positive integer.")
208
+
209
+ rng = Random(random_seed)
210
+ c_indices = set(original.c_symbols.values()) | set(simplified.c_symbols.values())
211
+
212
+ for trial in range(1, trials + 1):
213
+ a_value = _random_nonzero_rational(rng)
214
+ c_values = {
215
+ index: _random_nonzero_rational(rng)
216
+ for index in c_indices
217
+ }
218
+
219
+ original_value = _evaluate_parsed(original, a_value, c_values)
220
+ simplified_value = _evaluate_parsed(simplified, a_value, c_values)
221
+ difference = sp.simplify(original_value - simplified_value)
222
+ if _is_zero(difference):
223
+ continue
224
+
225
+ assignments = _format_assignments(a_value, c_values)
226
+ raise PolynomialVerificationError(
227
+ "verification failed on trial "
228
+ f"{trial}: original={original_value}, simplified={simplified_value}, "
229
+ f"assignments={assignments}"
230
+ )
231
+
232
+
233
+ def _evaluate_parsed(
234
+ parsed: _ParsedPolynomial,
235
+ a_value: sp.Rational,
236
+ c_values: dict[Index, sp.Rational],
237
+ ) -> sp.Expr:
238
+ substitutions: dict[sp.Symbol, sp.Rational] = {parsed.a_symbol: a_value}
239
+ substitutions.update(
240
+ {
241
+ symbol: c_values[index]
242
+ for symbol, index in parsed.c_symbols.items()
243
+ }
244
+ )
245
+ return sp.cancel(parsed.expr.subs(substitutions))
246
+
247
+
248
+ def _random_nonzero_rational(rng: Random) -> sp.Rational:
249
+ numerator = rng.choice([-9, -8, -7, -5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 7, 8, 9])
250
+ denominator = rng.choice([1, 2, 3, 5, 7])
251
+ return sp.Rational(numerator, denominator)
252
+
253
+
254
+ def _is_zero(value: sp.Expr) -> bool:
255
+ if value == 0:
256
+ return True
257
+ if value.is_number:
258
+ return abs(complex(sp.N(value))) < 1e-10
259
+ return False
260
+
261
+
262
+ def _format_assignments(
263
+ a_value: sp.Rational,
264
+ c_values: dict[Index, sp.Rational],
265
+ ) -> str:
266
+ parts = [f"A={a_value}"]
267
+ parts.extend(
268
+ f"C[{index[0]}, {index[1]}, {index[2]}]={value}"
269
+ for index, value in sorted(c_values.items())
270
+ )
271
+ return ", ".join(parts)
272
+
273
+
274
+ def _sympy_expr_to_c_string(expr: sp.Expr) -> str:
275
+ return sp.sstr(expr)
276
+
277
+
278
+ def _parse_polynomial(expression: str) -> _ParsedPolynomial:
279
+ if not isinstance(expression, str) or not expression.strip():
280
+ raise PolynomialParseError("expression must be a non-empty string.")
281
+
282
+ index_to_token: dict[Index, str] = {}
283
+ token_to_index: dict[str, Index] = {}
284
+
285
+ def replace_c(match: re.Match[str]) -> str:
286
+ index = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
287
+ token = index_to_token.get(index)
288
+ if token is None:
289
+ token = f"{_TOKEN_PREFIX}{len(index_to_token)}"
290
+ index_to_token[index] = token
291
+ token_to_index[token] = index
292
+ return f" {token} "
293
+
294
+ rewritten = _C_PATTERN.sub(replace_c, expression)
295
+ a_symbol = sp.Symbol("A")
296
+ token_symbols = {token: sp.Symbol(token) for token in token_to_index}
297
+ local_dict = {"A": a_symbol, **token_symbols}
298
+
299
+ try:
300
+ parsed = parse_expr(
301
+ rewritten,
302
+ local_dict=local_dict,
303
+ global_dict=dict(_SAFE_GLOBALS),
304
+ transformations=_TRANSFORMATIONS,
305
+ evaluate=True,
306
+ )
307
+ except Exception as exc:
308
+ raise PolynomialParseError(f"could not parse expression: {expression!r}") from exc
309
+
310
+ allowed_symbols = {a_symbol, *token_symbols.values()}
311
+ unknown_symbols = sorted(
312
+ str(symbol) for symbol in parsed.free_symbols if symbol not in allowed_symbols
313
+ )
314
+ if unknown_symbols:
315
+ names = ", ".join(unknown_symbols)
316
+ raise PolynomialParseError(f"unsupported symbol(s): {names}")
317
+
318
+ c_symbols = {
319
+ token_symbols[token]: index
320
+ for token, index in token_to_index.items()
321
+ }
322
+ return _ParsedPolynomial(parsed, a_symbol, c_symbols)
323
+
324
+
325
+ def _collect_terms(parsed: _ParsedPolynomial) -> list[_CollectedTerm]:
326
+ expr = sp.expand(parsed.expr)
327
+ c_items = sorted(parsed.c_symbols.items(), key=lambda item: item[1])
328
+
329
+ collected: dict[tuple[tuple[Index, int], ...], sp.Expr] = {}
330
+ for term in sp.Add.make_args(expr):
331
+ c_powers, coefficient = _split_c_factor(term, c_items)
332
+ collected[c_powers] = collected.get(c_powers, sp.Integer(0)) + coefficient
333
+
334
+ terms: list[_CollectedTerm] = []
335
+ for c_powers, coefficient in collected.items():
336
+ normalized = _normalize_a_polynomial(coefficient, parsed.a_symbol)
337
+ if normalized == 0:
338
+ continue
339
+ terms.append(_CollectedTerm(c_powers, normalized))
340
+
341
+ return sorted(terms, key=_term_sort_key)
342
+
343
+
344
+ def _split_c_factor(
345
+ term: sp.Expr,
346
+ c_items: list[tuple[sp.Symbol, Index]],
347
+ ) -> tuple[tuple[tuple[Index, int], ...], sp.Expr]:
348
+ powers = term.as_powers_dict()
349
+ coefficient = term
350
+ c_powers: list[tuple[Index, int]] = []
351
+
352
+ for symbol, index in c_items:
353
+ power = powers.get(symbol, sp.Integer(0))
354
+ if power == 0:
355
+ continue
356
+
357
+ int_power = _as_integer_power(
358
+ power,
359
+ "expression must be a Laurent polynomial in the indexed C terms.",
360
+ )
361
+ coefficient /= symbol ** int_power
362
+ c_powers.append((index, int_power))
363
+
364
+ coefficient = sp.cancel(coefficient)
365
+ if any(coefficient.has(symbol) for symbol, _index in c_items):
366
+ raise PolynomialParseError(
367
+ "expression must be a Laurent polynomial in the indexed C terms."
368
+ )
369
+
370
+ return tuple(c_powers), coefficient
371
+
372
+
373
+ def _normalize_a_polynomial(coefficient: sp.Expr, a_symbol: sp.Symbol) -> sp.Expr:
374
+ expanded = sp.expand(coefficient)
375
+ for term in sp.Add.make_args(expanded):
376
+ power = term.as_powers_dict().get(a_symbol, sp.Integer(0))
377
+ if power == 0:
378
+ a_free_part = term
379
+ else:
380
+ int_power = _as_integer_power(
381
+ power,
382
+ "coefficients must be Laurent polynomials in A.",
383
+ )
384
+ a_free_part = sp.cancel(term / (a_symbol ** int_power))
385
+
386
+ if a_free_part.has(a_symbol):
387
+ raise PolynomialParseError("coefficients must be Laurent polynomials in A.")
388
+
389
+ return sp.collect(expanded, a_symbol)
390
+
391
+
392
+ def _as_integer_power(power: sp.Expr, error_message: str) -> int:
393
+ if power.is_integer is not True:
394
+ raise PolynomialParseError(error_message)
395
+ return int(power)
396
+
397
+
398
+ def _term_sort_key(term: _CollectedTerm) -> tuple[int, tuple[tuple[Index, int], ...]]:
399
+ return (1, ()) if not term.c_powers else (0, term.c_powers)
400
+
401
+
402
+ def _format_terms(terms: list[_CollectedTerm], power_operator: PowerOperator) -> str:
403
+ if not terms:
404
+ return "0"
405
+
406
+ chunks: list[str] = []
407
+ for term in terms:
408
+ coefficient = term.coefficient
409
+
410
+ if term.c_powers:
411
+ body = (
412
+ f"{_format_c_factor(term.c_powers, power_operator)}"
413
+ f"*({_format_expr(coefficient, power_operator)})"
414
+ )
415
+ chunks.append(body if not chunks else f" + {body}")
416
+ continue
417
+
418
+ is_negative = coefficient.could_extract_minus_sign()
419
+ displayed_coefficient = -coefficient if is_negative else coefficient
420
+ body = _format_expr(displayed_coefficient, power_operator)
421
+
422
+ if not chunks:
423
+ chunks.append(f"-{body}" if is_negative else body)
424
+ else:
425
+ chunks.append(f" - {body}" if is_negative else f" + {body}")
426
+
427
+ return "".join(chunks)
428
+
429
+
430
+ def _format_expr(expr: sp.Expr, power_operator: PowerOperator) -> str:
431
+ a_symbol = sp.Symbol("A")
432
+ terms = [
433
+ _split_a_term(term, a_symbol)
434
+ for term in sp.Add.make_args(sp.expand(expr))
435
+ ]
436
+ terms.sort(key=lambda item: item[0], reverse=True)
437
+
438
+ chunks: list[str] = []
439
+ for power, coefficient in terms:
440
+ is_negative = coefficient.could_extract_minus_sign()
441
+ displayed_coefficient = -coefficient if is_negative else coefficient
442
+ body = _format_a_term(displayed_coefficient, power, power_operator)
443
+
444
+ if not chunks:
445
+ chunks.append(f"-{body}" if is_negative else body)
446
+ else:
447
+ chunks.append(f" - {body}" if is_negative else f" + {body}")
448
+
449
+ return "".join(chunks)
450
+
451
+
452
+ def _split_a_term(term: sp.Expr, a_symbol: sp.Symbol) -> tuple[int, sp.Expr]:
453
+ power = term.as_powers_dict().get(a_symbol, sp.Integer(0))
454
+ int_power = _as_integer_power(power, "coefficients must be Laurent polynomials in A.")
455
+ coefficient = sp.cancel(term / (a_symbol ** int_power))
456
+ return int_power, coefficient
457
+
458
+
459
+ def _format_a_term(
460
+ coefficient: sp.Expr,
461
+ power: int,
462
+ power_operator: PowerOperator,
463
+ ) -> str:
464
+ if power == 0:
465
+ return _format_number(coefficient, power_operator)
466
+
467
+ power_text = _format_a_power(power, power_operator)
468
+ if coefficient == 1:
469
+ return power_text
470
+
471
+ return f"{_format_number(coefficient, power_operator)}*{power_text}"
472
+
473
+
474
+ def _format_a_power(power: int, power_operator: PowerOperator) -> str:
475
+ if power == 1:
476
+ return "A"
477
+ if power < 0:
478
+ return f"A{power_operator}({power})"
479
+ return f"A{power_operator}{power}"
480
+
481
+
482
+ def _format_number(number: sp.Expr, power_operator: PowerOperator) -> str:
483
+ text = sp.sstr(number)
484
+ if power_operator == "^":
485
+ return text.replace("**", "^")
486
+ return text
487
+
488
+
489
+ def _format_c_factor(
490
+ c_powers: tuple[tuple[Index, int], ...],
491
+ power_operator: PowerOperator,
492
+ ) -> str:
493
+ factors: list[str] = []
494
+ for index, power in c_powers:
495
+ factor = _format_indexed_c(index)
496
+ if power != 1:
497
+ factor = f"{factor}{power_operator}{power}"
498
+ factors.append(factor)
499
+ return "*".join(factors)
500
+
501
+
502
+ def _format_indexed_c(index: Index) -> str:
503
+ x, y, z = index
504
+ return f"C[{x}, {y}, {z}]"
505
+
506
+
507
+ def _c_factor_expr(
508
+ c_powers: tuple[tuple[Index, int], ...],
509
+ c_base: sp.IndexedBase,
510
+ ) -> sp.Expr:
511
+ factors: list[sp.Expr] = []
512
+ for (x, y, z), power in c_powers:
513
+ indexed = c_base[x, y, z]
514
+ factors.append(indexed if power == 1 else sp.Pow(indexed, power))
515
+ return sp.Mul(*factors)
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: homlab-polynomial
3
+ Version: 0.1.0
4
+ Summary: Collect indexed C terms in polynomials with SymPy.
5
+ Author: GGN_2015
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: sympy>=1.10
11
+ Dynamic: license-file
12
+
13
+ # homlab_polynomial
14
+
15
+ `homlab_polynomial` 提供一个 Python 编程入口,用 SymPy 将包含普通变量 `A` 和三下标变量
16
+ `C[x, y, z]` 的字符串表达式整理成按不同 `C[x, y, z]` 分组的形式。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ pip install .
22
+ ```
23
+
24
+ ## 使用
25
+
26
+ ```python
27
+ from homlab_polynomial import organize_polynomial
28
+
29
+ expr = "A*C[1, 2, 3] + 2*C[1,2,3] + A^2*C[0,0,0]"
30
+ print(organize_polynomial(expr))
31
+ ```
32
+
33
+ 输出:
34
+
35
+ ```text
36
+ C[0, 0, 0]*(A^2) + C[1, 2, 3]*(A + 2)
37
+ ```
38
+
39
+ 负指数也支持:
40
+
41
+ ```python
42
+ organize_polynomial("A^-10*C[0,0,0] + 2*C[0,0,0]/A")
43
+ ```
44
+
45
+ 输出:
46
+
47
+ ```text
48
+ C[0, 0, 0]*(2*A^(-1) + A^(-10))
49
+ ```
50
+
51
+ 说明:
52
+
53
+ - `C[x, y, z]` 的三个下标必须是整数,可以为负数。
54
+ - 输入支持用 `^` 表示指数运算,也支持 Python/SymPy 风格的 `**`。
55
+ - 默认输出也使用 `^`。如果需要 `**`,可以传入 `output_power_operator="**"`。
56
+ - 系数支持有限 Laurent 多项式,因此 `A^-1`、`1/A`、`A^-10` 都可以使用。
57
+ - `C[x, y, z]` 项不会带外部负号;负号会保留在括号中的系数多项式里。
58
+ - 不含 `C` 的纯 `A` 项会作为最后的余项保留。
59
+
60
+ 如果需要继续做 SymPy 计算,可以使用:
61
+
62
+ ```python
63
+ from homlab_polynomial import organize_polynomial_expr
64
+
65
+ sympy_expr = organize_polynomial_expr("A*C[1,2,3] + C[1,2,3]")
66
+ ```
67
+
68
+ ## 验算
69
+
70
+ `organize_polynomial()` 默认会在每次整理后进行随机代入验算。它会给 `A` 和所有出现过的
71
+ `C[x, y, z]` 赋非零有理数,检查整理前后的表达式是否一致。
72
+
73
+ 也可以直接调用验算函数:
74
+
75
+ ```python
76
+ from homlab_polynomial import verify_simplification
77
+
78
+ verify_simplification(
79
+ "A*C[1,2,3] + C[1,2,3]",
80
+ "C[1, 2, 3]*(A + 1)",
81
+ )
82
+ ```
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/homlab_polynomial/__init__.py
5
+ src/homlab_polynomial/core.py
6
+ src/homlab_polynomial.egg-info/PKG-INFO
7
+ src/homlab_polynomial.egg-info/SOURCES.txt
8
+ src/homlab_polynomial.egg-info/dependency_links.txt
9
+ src/homlab_polynomial.egg-info/requires.txt
10
+ src/homlab_polynomial.egg-info/top_level.txt
11
+ tests/test_core.py
@@ -0,0 +1 @@
1
+ homlab_polynomial
@@ -0,0 +1,103 @@
1
+ import unittest
2
+
3
+ import sympy as sp
4
+
5
+ from homlab_polynomial import (
6
+ PolynomialParseError,
7
+ PolynomialVerificationError,
8
+ organize_polynomial,
9
+ organize_polynomial_expr,
10
+ verify_simplification,
11
+ )
12
+
13
+
14
+ class OrganizePolynomialTests(unittest.TestCase):
15
+ def test_collects_same_indexed_c_terms(self):
16
+ result = organize_polynomial(
17
+ "A*C[1, 2, 3] + 2*C[1,2,3] + A^2*C[0,0,0]"
18
+ )
19
+
20
+ self.assertEqual(
21
+ result,
22
+ "C[0, 0, 0]*(A^2) + C[1, 2, 3]*(A + 2)",
23
+ )
24
+
25
+ def test_supports_negative_indices_and_negative_coefficients(self):
26
+ result = organize_polynomial(
27
+ "C[-1,0,+2]*A^2 + 3*A*C[-1,0,2] - C[3,-4,5]"
28
+ )
29
+
30
+ self.assertEqual(
31
+ result,
32
+ "C[-1, 0, 2]*(A^2 + 3*A) + C[3, -4, 5]*(-1)",
33
+ )
34
+
35
+ def test_keeps_negative_sign_inside_c_coefficient(self):
36
+ result = organize_polynomial("-A*C[-2,0,1] - C[-2,0,1]")
37
+
38
+ self.assertEqual(result, "C[-2, 0, 1]*(-A - 1)")
39
+
40
+ def test_keeps_terms_without_c_as_remainder(self):
41
+ result = organize_polynomial("A^2 + C[0,0,0]*A + 1")
42
+
43
+ self.assertEqual(result, "C[0, 0, 0]*(A) + A^2 + 1")
44
+
45
+ def test_accepts_implicit_multiplication(self):
46
+ result = organize_polynomial("2C[1, 2, 3] A + C[1,2,3]")
47
+
48
+ self.assertEqual(result, "C[1, 2, 3]*(2*A + 1)")
49
+
50
+ def test_can_return_sympy_expression(self):
51
+ result = organize_polynomial_expr("A*C[1,2,3] + C[1,2,3]")
52
+ a = sp.Symbol("A")
53
+ c = sp.IndexedBase("C")
54
+
55
+ self.assertEqual(sp.expand(result - (a + 1) * c[1, 2, 3]), 0)
56
+
57
+ def test_verifies_equivalent_simplification(self):
58
+ self.assertTrue(
59
+ verify_simplification(
60
+ "A*C[1,2,3] + C[1,2,3]",
61
+ "C[1, 2, 3]*(A + 1)",
62
+ random_seed=123,
63
+ )
64
+ )
65
+
66
+ def test_verification_rejects_mismatch(self):
67
+ with self.assertRaises(PolynomialVerificationError):
68
+ verify_simplification(
69
+ "A*C[1,2,3] + C[1,2,3]",
70
+ "C[1, 2, 3]*(A + 2)",
71
+ random_seed=123,
72
+ )
73
+
74
+ def test_supports_negative_powers_of_a(self):
75
+ result = organize_polynomial("A^-10*C[0,0,0] + 2*C[0,0,0]/A")
76
+
77
+ self.assertEqual(result, "C[0, 0, 0]*(2*A^(-1) + A^(-10))")
78
+
79
+ def test_formats_one_over_a_as_negative_power(self):
80
+ result = organize_polynomial("C[0,0,0]/A")
81
+
82
+ self.assertEqual(result, "C[0, 0, 0]*(A^(-1))")
83
+
84
+ def test_supports_negative_powers_of_c(self):
85
+ result = organize_polynomial("A*C[0,0,0]^-1 + 2*C[0,0,0]^-1")
86
+
87
+ self.assertEqual(result, "C[0, 0, 0]^-1*(A + 2)")
88
+
89
+ def test_rejects_unknown_symbols(self):
90
+ with self.assertRaises(PolynomialParseError):
91
+ organize_polynomial("B*C[0,0,0]")
92
+
93
+ def test_rejects_non_laurent_coefficients(self):
94
+ with self.assertRaises(PolynomialParseError):
95
+ organize_polynomial("C[0,0,0]/(A + 1)")
96
+
97
+ def test_rejects_non_laurent_c_terms(self):
98
+ with self.assertRaises(PolynomialParseError):
99
+ organize_polynomial("A/(C[0,0,0] + 1)")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ unittest.main()