openfll-python 0.2.0__cp314-cp314-win_amd64.whl

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.
openfll/__init__.py ADDED
@@ -0,0 +1,463 @@
1
+ """Публичный Python API для OpenFLL.
2
+
3
+ `openfll` — это пакет, предназначенный для непосредственного использования.
4
+ Во время выполнения он делегирует задачи внутреннему пакету-бэкенду `PyFLL`, который содержит модуль расширения на машинном коде `PyFLL._core`.
5
+
6
+ Ожидаемая цепочка вызовов во время выполнения:
7
+
8
+ openfll -> PyFLL -> PyFLL._core -> OFLL::Linear::SugenoEngine
9
+
10
+ Приведенный ниже класс сохраняется для целей статического анализа и генерации документации.
11
+ При наличии встроенного бэкенда `SugenoEngine` заменяется на `PyFLL.SugenoEngine`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ from collections.abc import Mapping, Sequence
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any, Literal
19
+ from weakref import WeakKeyDictionary
20
+
21
+ from ._helpers.plotting import (
22
+ MembershipPlot,
23
+ _register_membership_function,
24
+ _register_variable,
25
+ plot_mf,
26
+ )
27
+
28
+ try:
29
+ import numpy as _np
30
+ except ImportError:
31
+ _np = None # type: ignore[assignment]
32
+
33
+ __all__ = [
34
+ "SugenoEngine",
35
+ "MfType",
36
+ "TNorms",
37
+ "SNorms",
38
+ "constant",
39
+ "triangular",
40
+ "trapezoidal",
41
+ "gaussian",
42
+ "MembershipPlot",
43
+ "plot_mf",
44
+ ]
45
+
46
+ _BACKEND_IMPORT_ERROR: BaseException | None = None
47
+ _ENGINE_INPUT_NAMES: WeakKeyDictionary[object, list[str]] = WeakKeyDictionary()
48
+ _ENGINE_OUTPUT_NAMES: WeakKeyDictionary[object, list[str]] = WeakKeyDictionary()
49
+
50
+
51
+ def _backend_error_message() -> str:
52
+ reason = ""
53
+ if _BACKEND_IMPORT_ERROR is not None:
54
+ reason = (
55
+ f"\nИсходная причина: "
56
+ f"{type(_BACKEND_IMPORT_ERROR).__name__}: {_BACKEND_IMPORT_ERROR}"
57
+ )
58
+ return (
59
+ "openfll не смог загрузить внутренний backend `PyFLL` или его "
60
+ "native module `PyFLL._core`.\n"
61
+ "Проверьте, что пакет установлен из wheel, собранного вместе с "
62
+ "native backend: `pip install openfll_python`."
63
+ f"{reason}"
64
+ )
65
+
66
+
67
+ # ============================================================================
68
+ # Literal типы — экспортируются для IDE/mkdocstrings
69
+ # ============================================================================
70
+ MfType = Literal[
71
+ "constant", "triangular", "trapezoidal", "gaussian"
72
+ ]
73
+ TNorms = Literal[
74
+ "min", "prod_and", "bounded_diff", "drastic_prod", "einstein_prod", "hamacher_prod"
75
+ ]
76
+ SNorms = Literal[
77
+ "max", "algebraic_sum", "bounded_sum", "drastic_sum", "einstein_sum", "hamacher_sum"
78
+ ]
79
+
80
+
81
+ def constant(value: float) -> list[float]:
82
+ """Вернуть параметры для constant membership function."""
83
+ return [value]
84
+
85
+
86
+ def triangular(a: float, b: float, c: float) -> list[float]:
87
+ """Вернуть параметры для triangular membership function в порядке a, b, c."""
88
+ return [a, b, c]
89
+
90
+
91
+ def trapezoidal(a: float, b: float, c: float, d: float) -> list[float]:
92
+ """Вернуть параметры для trapezoidal membership function в порядке a, b, c, d."""
93
+ return [a, b, c, d]
94
+
95
+
96
+ def gaussian(*, center: float, sigma: float) -> list[float]:
97
+ """Вернуть параметры для gaussian membership function в порядке center, sigma."""
98
+ return [center, sigma]
99
+
100
+
101
+ def _install_scalar_predict(engine_cls: type) -> None:
102
+ """Добавить Python convenience predict к настоящему native engine class."""
103
+ required_methods = (
104
+ "add_input_var",
105
+ "add_output_var",
106
+ "add_membership_func",
107
+ "set_input",
108
+ "calculate",
109
+ "get_output",
110
+ )
111
+ if getattr(engine_cls, "_openfll_predict_installed", False):
112
+ return
113
+ if not all(hasattr(engine_cls, name) for name in required_methods):
114
+ return
115
+
116
+ native_add_input_var = engine_cls.add_input_var
117
+ native_add_output_var = engine_cls.add_output_var
118
+ native_add_membership_func = engine_cls.add_membership_func
119
+
120
+ def add_input_var(self, name: str) -> None:
121
+ native_add_input_var(self, name)
122
+ _ENGINE_INPUT_NAMES.setdefault(self, []).append(name)
123
+ _register_variable(self, name, "input")
124
+
125
+ def add_output_var(self, name: str) -> None:
126
+ native_add_output_var(self, name)
127
+ _ENGINE_OUTPUT_NAMES.setdefault(self, []).append(name)
128
+ _register_variable(self, name, "output")
129
+
130
+ def add_membership_func(
131
+ self,
132
+ var_name: str,
133
+ mf_name: str,
134
+ type: str,
135
+ params: Sequence[float],
136
+ ) -> None:
137
+ native_add_membership_func(self, var_name, mf_name, type, params)
138
+ _register_membership_function(self, var_name, mf_name, type, params)
139
+
140
+ def _is_bool(val: Any) -> bool:
141
+ if isinstance(val, bool):
142
+ return True
143
+ if _np is not None:
144
+ if isinstance(val, (_np.bool_,)):
145
+ return True
146
+ if isinstance(val, _np.ndarray):
147
+ if val.dtype == bool or _np.issubdtype(val.dtype, _np.bool_):
148
+ return True
149
+ if val.ndim == 0 and isinstance(val.item(), bool):
150
+ return True
151
+ return False
152
+
153
+ def _contains_bool(seq: Any) -> bool:
154
+ if isinstance(seq, (list, tuple)):
155
+ for item in seq:
156
+ if _is_bool(item) or _contains_bool(item):
157
+ return True
158
+ elif _np is not None and isinstance(seq, _np.ndarray):
159
+ if seq.dtype == bool or _np.issubdtype(seq.dtype, _np.bool_):
160
+ return True
161
+ return False
162
+
163
+ def _validate_output_name(self, output_name: str | None) -> str:
164
+ output_names = _ENGINE_OUTPUT_NAMES.get(self, [])
165
+ if output_name is not None:
166
+ if output_names and output_name not in output_names:
167
+ raise KeyError(
168
+ f"Output variable '{output_name}' is not registered in engine. "
169
+ f"Registered outputs: {', '.join(output_names)}"
170
+ )
171
+ return output_name
172
+ if len(output_names) == 1:
173
+ return output_names[0]
174
+ if len(output_names) == 0:
175
+ raise ValueError("No output variables registered through add_output_var")
176
+ raise ValueError(
177
+ "predict requires output_name unless exactly one output "
178
+ "variable was registered through add_output_var"
179
+ )
180
+
181
+ def _run_scalar(
182
+ self,
183
+ scalar_inputs: Mapping[str, float],
184
+ output_name: str | None = None,
185
+ ) -> float:
186
+ input_names = _ENGINE_INPUT_NAMES.get(self, [])
187
+ if input_names:
188
+ missing = [name for name in input_names if name not in scalar_inputs]
189
+ if missing:
190
+ raise ValueError(
191
+ "predict inputs missing registered input variables: "
192
+ + ", ".join(missing)
193
+ )
194
+
195
+ resolved_output_name = _validate_output_name(self, output_name)
196
+
197
+ for name, value in scalar_inputs.items():
198
+ if _is_bool(value):
199
+ raise TypeError(
200
+ f"Value for input variable '{name}' cannot be boolean, got {value!r}"
201
+ )
202
+ self.set_input(name, float(value))
203
+ self.calculate()
204
+ return self.get_output(resolved_output_name)
205
+
206
+ def predict(
207
+ self,
208
+ inputs: Any,
209
+ output_name: str | None = None,
210
+ *,
211
+ input_names: Sequence[str] | None = None,
212
+ ) -> Any:
213
+ """Выполнить inference для скаляра, словаря, последовательности или NumPy-батча.
214
+
215
+ Предупреждение:
216
+ Инстанс не является потокобезопасным для инференса. Для многопоточной
217
+ обработки необходимо клонировать модель или создать независимые экземпляры.
218
+ """
219
+ if _is_bool(inputs):
220
+ raise TypeError("inputs cannot be a boolean value")
221
+
222
+ if isinstance(inputs, Mapping):
223
+ return _run_scalar(self, inputs, output_name)
224
+
225
+ if _np is not None and isinstance(inputs, _np.ndarray):
226
+ if inputs.dtype == bool or _np.issubdtype(inputs.dtype, _np.bool_):
227
+ raise TypeError("boolean numpy arrays are not supported")
228
+
229
+ if inputs.ndim > 2:
230
+ raise ValueError(
231
+ f"X must be a 1D or 2D array, got {inputs.ndim}D array with shape {inputs.shape}"
232
+ )
233
+
234
+ if inputs.ndim == 0:
235
+ val = inputs.item()
236
+ if _is_bool(val):
237
+ raise TypeError("inputs cannot be a boolean value")
238
+ inputs = float(val)
239
+ # Переходит к ветке is_scalar ниже
240
+
241
+ elif inputs.ndim == 2:
242
+ _validate_output_name(self, output_name)
243
+ resolved_input_names = input_names
244
+ if resolved_input_names is None:
245
+ registered = _ENGINE_INPUT_NAMES.get(self, [])
246
+ if not registered:
247
+ raise ValueError(
248
+ "predict with 2D array requires input_names or previously registered input variables"
249
+ )
250
+ resolved_input_names = registered
251
+ elif len(resolved_input_names) != inputs.shape[1]:
252
+ raise ValueError(
253
+ f"Expected {inputs.shape[1]} input_names, got {len(resolved_input_names)}"
254
+ )
255
+ return self.predict_batch(inputs, input_names=resolved_input_names)
256
+
257
+ elif inputs.ndim == 1:
258
+ registered = _ENGINE_INPUT_NAMES.get(self, [])
259
+ if len(registered) == 1:
260
+ _validate_output_name(self, output_name)
261
+ if input_names is not None:
262
+ if len(input_names) != 1 or input_names[0] != registered[0]:
263
+ raise ValueError(
264
+ f"Expected input_names={registered}, got {list(input_names)}"
265
+ )
266
+ resolved_input_names = input_names
267
+ else:
268
+ resolved_input_names = registered
269
+ return self.predict_batch(inputs.reshape(-1, 1), input_names=resolved_input_names)
270
+
271
+ if len(registered) > 1 and inputs.shape[0] == len(registered):
272
+ if input_names is not None:
273
+ if len(input_names) != len(registered):
274
+ raise ValueError(
275
+ f"Expected {len(registered)} input_names, got {len(input_names)}"
276
+ )
277
+ missing = [name for name in input_names if name not in registered]
278
+ if missing:
279
+ raise ValueError(f"Unknown input_names: {', '.join(missing)}")
280
+ names_to_use = list(input_names)
281
+ else:
282
+ names_to_use = registered
283
+ sample_dict = {name: float(val) for name, val in zip(names_to_use, inputs)}
284
+ return _run_scalar(self, sample_dict, output_name)
285
+
286
+ raise ValueError(
287
+ f"1D array of shape {inputs.shape} does not match {len(registered)} registered input variables ({', '.join(registered)})"
288
+ )
289
+
290
+ is_scalar = (
291
+ isinstance(inputs, (int, float))
292
+ or (_np is not None and isinstance(inputs, _np.number))
293
+ ) and not _is_bool(inputs)
294
+
295
+ if is_scalar:
296
+ registered = _ENGINE_INPUT_NAMES.get(self, [])
297
+ if len(registered) == 1:
298
+ return _run_scalar(self, {registered[0]: float(inputs)}, output_name)
299
+ if len(registered) == 0:
300
+ raise ValueError("No input variables registered to map scalar input")
301
+ raise ValueError(
302
+ f"Model requires {len(registered)} inputs ({', '.join(registered)}), "
303
+ f"but a single scalar was provided. Provide a dict, sequence, or 2D array."
304
+ )
305
+
306
+ if isinstance(inputs, (list, tuple)):
307
+ if len(inputs) == 0:
308
+ raise ValueError("inputs sequence cannot be empty")
309
+ if _contains_bool(inputs):
310
+ raise TypeError("inputs sequence cannot contain boolean values")
311
+
312
+ # Проверка на 2D последовательность (батч сэмплов)
313
+ if isinstance(inputs[0], (list, tuple)) or (_np is not None and isinstance(inputs[0], _np.ndarray)):
314
+ if _np is None:
315
+ raise RuntimeError("NumPy is required for batch inference")
316
+ _validate_output_name(self, output_name)
317
+ arr = _np.asarray(inputs)
318
+ if arr.ndim != 2:
319
+ raise ValueError(f"X must be a 1D or 2D sequence, got {arr.ndim}D sequence with shape {arr.shape}")
320
+ resolved_input_names = input_names
321
+ if resolved_input_names is None:
322
+ registered = _ENGINE_INPUT_NAMES.get(self, [])
323
+ if not registered:
324
+ raise ValueError(
325
+ "predict with batch sequence requires input_names or previously registered input variables"
326
+ )
327
+ resolved_input_names = registered
328
+ elif len(resolved_input_names) != arr.shape[1]:
329
+ raise ValueError(
330
+ f"Expected {arr.shape[1]} input_names, got {len(resolved_input_names)}"
331
+ )
332
+ return self.predict_batch(arr, input_names=resolved_input_names)
333
+
334
+ # 1D последовательность
335
+ registered = _ENGINE_INPUT_NAMES.get(self, [])
336
+ if len(registered) == 1:
337
+ if input_names is not None:
338
+ if len(input_names) != 1 or input_names[0] != registered[0]:
339
+ raise ValueError(
340
+ f"Expected input_names={registered}, got {list(input_names)}"
341
+ )
342
+ resolved_input_names = input_names
343
+ else:
344
+ resolved_input_names = registered
345
+
346
+ if len(inputs) > 1:
347
+ if _np is None:
348
+ raise RuntimeError("NumPy is required for batch inference")
349
+ _validate_output_name(self, output_name)
350
+ arr = _np.asarray(inputs, dtype=_np.float64).reshape(-1, 1)
351
+ return self.predict_batch(arr, input_names=resolved_input_names)
352
+
353
+ # Единичный сэмпл из 1 значения
354
+ sample_dict = {resolved_input_names[0]: float(inputs[0])}
355
+ return _run_scalar(self, sample_dict, output_name)
356
+
357
+ # len(registered) > 1 (многовходовая модель)
358
+ if len(inputs) != len(registered):
359
+ raise ValueError(
360
+ f"Expected {len(registered)} inputs ({', '.join(registered)}), but got {len(inputs)} values in sequence"
361
+ )
362
+ if input_names is not None:
363
+ if len(input_names) != len(registered):
364
+ raise ValueError(
365
+ f"Expected {len(registered)} input_names, got {len(input_names)}"
366
+ )
367
+ missing = [name for name in input_names if name not in registered]
368
+ if missing:
369
+ raise ValueError(f"Unknown input_names: {', '.join(missing)}")
370
+ names_to_use = list(input_names)
371
+ else:
372
+ names_to_use = registered
373
+ sample_dict = {name: float(val) for name, val in zip(names_to_use, inputs)}
374
+ return _run_scalar(self, sample_dict, output_name)
375
+
376
+ raise TypeError(
377
+ f"inputs must be a mapping, scalar number, sequence, or numpy ndarray, got {type(inputs).__name__}"
378
+ )
379
+
380
+ def plot_membership_functions(
381
+ self,
382
+ variables: str | Sequence[str] | None = None,
383
+ *,
384
+ variable_type: Literal["all", "input", "output"] = "all",
385
+ num_points: int = 181,
386
+ ranges: Mapping[str, tuple[float, float]] | None = None,
387
+ show: bool = True,
388
+ save_dir: str | Path | None = None,
389
+ ) -> dict[str, MembershipPlot]:
390
+ """Построить функции принадлежности, зарегистрированные в движке."""
391
+ return plot_mf(
392
+ self,
393
+ variables,
394
+ variable_type=variable_type,
395
+ num_points=num_points,
396
+ ranges=ranges,
397
+ show=show,
398
+ save_dir=save_dir,
399
+ )
400
+
401
+ engine_cls.add_input_var = add_input_var
402
+ engine_cls.add_output_var = add_output_var
403
+ engine_cls.add_membership_func = add_membership_func
404
+ engine_cls.predict = predict
405
+ engine_cls.plot_mf = plot_membership_functions
406
+ engine_cls._openfll_predict_installed = True
407
+
408
+
409
+ class SugenoEngine:
410
+ """Placeholder, который заменяется реальным PyFLL.SugenoEngine."""
411
+
412
+ def __init__(self) -> None:
413
+ """Создать engine или явно сообщить, что native backend не загружен."""
414
+ raise ImportError(_backend_error_message()) from _BACKEND_IMPORT_ERROR
415
+
416
+
417
+ # ============================================================================
418
+ # Время выполнения: загрузка существующего пакета бэкенда. openfll намеренно не
419
+ # импортирует и не включает в поставку собственное расширение _core.
420
+ # ============================================================================
421
+ try:
422
+ _PyFLL = importlib.import_module("PyFLL")
423
+ _RealSugenoEngine = _PyFLL.SugenoEngine
424
+ if getattr(_RealSugenoEngine, "__module__", "") != "PyFLL._core":
425
+ raise ImportError(
426
+ "PyFLL импортирован, но PyFLL.SugenoEngine не загружен из "
427
+ f"PyFLL._core (получен модуль "
428
+ f"{getattr(_RealSugenoEngine, '__module__', '<unknown>')!r})"
429
+ )
430
+
431
+ SugenoEngine = _RealSugenoEngine
432
+ _install_scalar_predict(SugenoEngine)
433
+ del _PyFLL
434
+ del _RealSugenoEngine
435
+ except ImportError as exc:
436
+ # Для документации и type checking стаб остаётся доступным, но в обычном
437
+ # runtime его нельзя использовать как рабочий engine.
438
+ _BACKEND_IMPORT_ERROR = exc
439
+ pass
440
+
441
+
442
+ # ============================================================================
443
+ # Python-only helpers (`openfll._helpers.*`).
444
+ #
445
+ # Эти модули используют ТОЛЬКО публичный Python-API (SugenoEngine) и
446
+ # развиваются независимо от C++ ядра. В минорных релизах wheel они могут
447
+ # расти свободно, не требуя пересборки .pyd.
448
+ #
449
+ # Импортируем здесь, чтобы коллеги могли:
450
+ # from openfll import monte_carlo
451
+ # вместо
452
+ # from openfll._helpers.sampling import monte_carlo
453
+ # ============================================================================
454
+ if TYPE_CHECKING:
455
+ # Для IDE / mypy / mkdocstrings — type hints подтягиваются без runtime-импорта.
456
+ from ._helpers.sampling import check_output_finite, grid_2d, monte_carlo
457
+ else:
458
+ try:
459
+ from ._helpers.sampling import check_output_finite, grid_2d, monte_carlo
460
+ __all__ = [*__all__, "monte_carlo", "grid_2d", "check_output_finite"]
461
+ except ImportError:
462
+ # Минимальная установка (только wheel, без _helpers) — fallback.
463
+ pass