Coverage for anfis_toolbox / builders.py: 100%

451 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-05 18:47 -0300

1"""Builder classes for easy ANFIS model construction.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Sequence 

6from typing import TypeAlias, cast 

7 

8import numpy as np 

9from numpy.random import Generator 

10from numpy.typing import ArrayLike, NDArray 

11 

12from .clustering import FuzzyCMeans 

13from .membership import ( 

14 BellMF, 

15 DiffSigmoidalMF, 

16 Gaussian2MF, 

17 GaussianMF, 

18 LinSShapedMF, 

19 LinZShapedMF, 

20 MembershipFunction, 

21 PiMF, 

22 ProdSigmoidalMF, 

23 SigmoidalMF, 

24 SShapedMF, 

25 TrapezoidalMF, 

26 TriangularMF, 

27 ZShapedMF, 

28) 

29from .model import TSKANFIS 

30 

31MembershipFactory = Callable[[float, float, int, float], list[MembershipFunction]] 

32RandomStateLike: TypeAlias = int | Generator | None 

33FloatArray1D: TypeAlias = NDArray[np.float64] 

34FloatArray2D: TypeAlias = NDArray[np.float64] 

35 

36 

37class ANFISBuilder: 

38 """Builder class for creating ANFIS models with intuitive API.""" 

39 

40 def __init__(self) -> None: 

41 """Initialize the ANFIS builder.""" 

42 self.input_mfs: dict[str, list[MembershipFunction]] = {} 

43 self.input_ranges: dict[str, tuple[float, float]] = {} 

44 self._rules: list[tuple[int, ...]] | None = None 

45 # Centralized dispatch for MF creators (supports aliases) 

46 dispatch: dict[str, MembershipFactory] = { 

47 # Canonical 

48 "gaussian": cast(MembershipFactory, self._create_gaussian_mfs), 

49 "gaussian2": cast(MembershipFactory, self._create_gaussian2_mfs), 

50 "triangular": cast(MembershipFactory, self._create_triangular_mfs), 

51 "trapezoidal": cast(MembershipFactory, self._create_trapezoidal_mfs), 

52 "bell": cast(MembershipFactory, self._create_bell_mfs), 

53 "sigmoidal": cast(MembershipFactory, self._create_sigmoidal_mfs), 

54 "sshape": cast(MembershipFactory, self._create_sshape_mfs), 

55 "zshape": cast(MembershipFactory, self._create_zshape_mfs), 

56 "pi": cast(MembershipFactory, self._create_pi_mfs), 

57 "linsshape": cast(MembershipFactory, self._create_linsshape_mfs), 

58 "linzshape": cast(MembershipFactory, self._create_linzshape_mfs), 

59 "diffsigmoidal": cast(MembershipFactory, self._create_diff_sigmoidal_mfs), 

60 "prodsigmoidal": cast(MembershipFactory, self._create_prod_sigmoidal_mfs), 

61 # Aliases 

62 "gbell": cast(MembershipFactory, self._create_bell_mfs), 

63 "sigmoid": cast(MembershipFactory, self._create_sigmoidal_mfs), 

64 "s": cast(MembershipFactory, self._create_sshape_mfs), 

65 "z": cast(MembershipFactory, self._create_zshape_mfs), 

66 "pimf": cast(MembershipFactory, self._create_pi_mfs), 

67 "ls": cast(MembershipFactory, self._create_linsshape_mfs), 

68 "lz": cast(MembershipFactory, self._create_linzshape_mfs), 

69 "diffsigmoid": cast(MembershipFactory, self._create_diff_sigmoidal_mfs), 

70 "prodsigmoid": cast(MembershipFactory, self._create_prod_sigmoidal_mfs), 

71 } 

72 self._dispatch: dict[str, MembershipFactory] = dispatch 

73 

74 def add_input( 

75 self, 

76 name: str, 

77 range_min: float, 

78 range_max: float, 

79 n_mfs: int = 3, 

80 mf_type: str = "gaussian", 

81 overlap: float = 0.5, 

82 ) -> ANFISBuilder: 

83 """Add an input variable with automatic membership function generation. 

84 

85 Parameters: 

86 name: Name of the input variable 

87 range_min: Minimum value of the input range 

88 range_max: Maximum value of the input range 

89 n_mfs: Number of membership functions (default: 3) 

90 mf_type: Type of membership functions. Supported: 

91 'gaussian', 'gaussian2', 'triangular', 'trapezoidal', 

92 'bell', 'sigmoidal', 'sshape', 'zshape', 'pi' 

93 overlap: Overlap factor between adjacent MFs (0.0 to 1.0) 

94 

95 Returns: 

96 Self for method chaining 

97 """ 

98 self.input_ranges[name] = (range_min, range_max) 

99 

100 mf_key = mf_type.strip().lower() 

101 factory = self._dispatch.get(mf_key) 

102 if factory is None: 

103 supported = ", ".join(sorted(set(self._dispatch.keys()))) 

104 raise ValueError(f"Unknown membership function type: {mf_type}. Supported: {supported}") 

105 self.input_mfs[name] = factory(range_min, range_max, n_mfs, overlap) 

106 

107 return self 

108 

109 def add_input_from_data( 

110 self, 

111 name: str, 

112 data: ArrayLike, 

113 n_mfs: int = 3, 

114 mf_type: str = "gaussian", 

115 overlap: float = 0.5, 

116 margin: float = 0.10, 

117 init: str | None = "grid", 

118 random_state: RandomStateLike = None, 

119 ) -> ANFISBuilder: 

120 """Add an input inferring range_min/range_max from data with a margin. 

121 

122 Parameters: 

123 name: Input name 

124 data: 1D array-like samples for this input 

125 n_mfs: Number of membership functions 

126 mf_type: Membership function type (see add_input) 

127 overlap: Overlap factor between adjacent MFs 

128 margin: Fraction of (max-min) to pad on each side 

129 init: Initialization strategy: "grid" (default), "fcm", "random", or ``None``. When ``"fcm"``, 

130 clusters from the data determine MF centers and widths (supports 

131 'gaussian' and 'bell'). 

132 random_state: Optional seed for deterministic FCM initialization. 

133 """ 

134 arr = cast(FloatArray1D, np.asarray(data, dtype=np.float64).reshape(-1)) 

135 

136 if init is None: 

137 strategy = "grid" 

138 else: 

139 strategy = str(init).strip().lower() 

140 

141 if strategy == "fcm": 

142 self.input_mfs[name] = self._create_mfs_from_fcm(arr, n_mfs, mf_type, random_state) 

143 self.input_ranges[name] = (float(np.min(arr)), float(np.max(arr))) 

144 return self 

145 if strategy == "random": 

146 return self._add_input_random( 

147 name=name, 

148 data=arr, 

149 n_mfs=n_mfs, 

150 mf_type=mf_type, 

151 overlap=overlap, 

152 margin=margin, 

153 random_state=random_state, 

154 ) 

155 if strategy != "grid": 

156 supported = "grid, fcm, random" 

157 raise ValueError(f"Unknown init strategy '{init}'. Supported: {supported}") 

158 

159 rmin = float(np.min(arr)) 

160 rmax = float(np.max(arr)) 

161 pad = (rmax - rmin) * float(margin) 

162 return self.add_input(name, rmin - pad, rmax + pad, n_mfs, mf_type, overlap) 

163 

164 # FCM-based MF creation for 1D inputs 

165 def _create_mfs_from_fcm( 

166 self, 

167 data_1d: FloatArray1D, 

168 n_mfs: int, 

169 mf_type: str, 

170 random_state: RandomStateLike, 

171 ) -> list[MembershipFunction]: 

172 """Create membership functions from 1D data via FCM.""" 

173 x = cast(FloatArray2D, np.asarray(data_1d, dtype=np.float64).reshape(-1, 1)) 

174 if x.shape[0] < n_mfs: 

175 raise ValueError("n_samples must be >= n_mfs for FCM initialization") 

176 

177 if isinstance(random_state, Generator): 

178 fcm_seed: int | None = int(random_state.integers(0, 2**32 - 1)) 

179 else: 

180 fcm_seed = random_state 

181 

182 fcm = FuzzyCMeans(n_clusters=n_mfs, m=2.0, random_state=fcm_seed) 

183 fcm.fit(x) 

184 centers_arr = fcm.cluster_centers_ 

185 membership = fcm.membership_ 

186 if centers_arr is None or membership is None: 

187 raise RuntimeError("FCM did not produce centers or membership values; ensure fit succeeded.") 

188 

189 centers = centers_arr.reshape(-1) 

190 U = membership 

191 m = fcm.m 

192 

193 diffs = x[:, 0][:, None] - centers[None, :] 

194 num = np.sum((U**m) * (diffs * diffs), axis=0) 

195 den = np.maximum(np.sum(U**m, axis=0), 1e-12) 

196 sigmas = np.sqrt(num / den) 

197 

198 spacing = np.diff(np.sort(centers)) 

199 default_sigma = float(np.median(spacing)) if spacing.size else max(float(np.std(x)), 1e-3) 

200 sigmas = np.where(sigmas > 1e-12, sigmas, max(default_sigma, 1e-3)) 

201 

202 order = np.argsort(centers) 

203 centers = centers[order] 

204 sigmas = sigmas[order] 

205 

206 key = mf_type.strip().lower() 

207 rmin = float(np.min(x)) 

208 rmax = float(np.max(x)) 

209 min_w = max(float(np.median(np.diff(np.sort(centers)))) if centers.size > 1 else float(np.std(x)), 1e-3) 

210 widths = np.maximum(2.0 * sigmas, min_w) 

211 

212 return self._build_mfs_from_layout(key, centers, sigmas, widths, rmin, rmax) 

213 

214 def _add_input_random( 

215 self, 

216 name: str, 

217 data: ArrayLike, 

218 n_mfs: int, 

219 mf_type: str, 

220 overlap: float, 

221 margin: float, 

222 random_state: RandomStateLike, 

223 ) -> ANFISBuilder: 

224 x = cast(FloatArray1D, np.asarray(data, dtype=np.float64).reshape(-1)) 

225 if x.size == 0: 

226 raise ValueError("Cannot initialize membership functions from empty data array") 

227 

228 rmin = float(np.min(x)) 

229 rmax = float(np.max(x)) 

230 pad = (rmax - rmin) * float(margin) 

231 low = rmin - pad 

232 high = rmax + pad 

233 

234 if isinstance(random_state, Generator): 

235 rng = random_state 

236 else: 

237 rng = np.random.default_rng(random_state) 

238 

239 centers = np.sort(rng.uniform(low, high, size=max(int(n_mfs), 1))) 

240 if centers.size == 1: 

241 widths = np.array([max(high - low, 1e-3)]) 

242 else: 

243 diffs = np.diff(centers) 

244 left = np.concatenate(([diffs[0]], diffs)) 

245 right = np.concatenate((diffs, [diffs[-1]])) 

246 widths = (left + right) / 2.0 

247 

248 overlap = float(overlap) 

249 base_span = (high - low) / max(centers.size, 1) 

250 floor = max(base_span * max(overlap, 0.1), 1e-3) 

251 widths = np.maximum(widths, floor) 

252 sigmas = np.maximum(widths / 2.0, 1e-3) 

253 

254 key = mf_type.strip().lower() 

255 mfs = self._build_mfs_from_layout(key, centers, sigmas, widths, low, high) 

256 

257 self.input_ranges[name] = (low, high) 

258 self.input_mfs[name] = mfs 

259 return self 

260 

261 def set_rules(self, rules: Sequence[Sequence[int]] | None) -> ANFISBuilder: 

262 """Define an explicit set of fuzzy rules to use when building the model. 

263 

264 Parameters: 

265 rules: Iterable of rules where each rule lists the membership index per input. 

266 ``None`` removes any previously configured custom rules and restores the 

267 default Cartesian-product behaviour. 

268 

269 Returns: 

270 Self for method chaining. 

271 """ 

272 if rules is None: 

273 self._rules = None 

274 return self 

275 

276 normalized: list[tuple[int, ...]] = [] 

277 for rule in rules: 

278 normalized.append(tuple(int(idx) for idx in rule)) 

279 if not normalized: 

280 raise ValueError("Rules sequence cannot be empty; pass None to restore defaults.") 

281 self._rules = normalized 

282 return self 

283 

284 def _build_mfs_from_layout( 

285 self, 

286 key: str, 

287 centers: np.ndarray, 

288 sigmas: np.ndarray, 

289 widths: np.ndarray, 

290 rmin: float, 

291 rmax: float, 

292 ) -> list[MembershipFunction]: 

293 if key == "gaussian": 

294 return [GaussianMF(mean=float(c), sigma=float(s)) for c, s in zip(centers, sigmas, strict=False)] 

295 if key == "gaussian2": 

296 gaussian2_mfs: list[MembershipFunction] = [] 

297 plateau_frac = 0.3 

298 for c, s, w in zip(centers, sigmas, widths, strict=False): 

299 half_plateau = (w * plateau_frac) / 2.0 

300 c1 = float(max(c - half_plateau, rmin)) 

301 c2 = float(min(c + half_plateau, rmax)) 

302 if not (c1 < c2): 

303 eps = 1e-6 

304 c1, c2 = c - eps, c + eps 

305 gaussian2_mfs.append(Gaussian2MF(sigma1=float(s), c1=c1, sigma2=float(s), c2=c2)) 

306 return gaussian2_mfs 

307 if key in {"bell", "gbell"}: 

308 return [BellMF(a=float(s), b=2.0, c=float(c)) for c, s in zip(centers, sigmas, strict=False)] 

309 if key == "triangular": 

310 triangular_mfs: list[MembershipFunction] = [] 

311 for c, w in zip(centers, widths, strict=False): 

312 a = float(max(c - w / 2.0, rmin)) 

313 cc = float(min(c + w / 2.0, rmax)) 

314 b = float(c) 

315 if not (a < b < cc): 

316 eps = 1e-6 

317 a, b, cc = c - 2 * eps, c, c + 2 * eps 

318 triangular_mfs.append(TriangularMF(a, b, cc)) 

319 return triangular_mfs 

320 if key == "trapezoidal": 

321 trapezoidal_mfs: list[MembershipFunction] = [] 

322 plateau_frac = 0.3 

323 for c, w in zip(centers, widths, strict=False): 

324 a = float(c - w / 2.0) 

325 d = float(c + w / 2.0) 

326 b = float(a + (w * (1 - plateau_frac)) / 2.0) 

327 cr = float(b + w * plateau_frac) 

328 a = max(a, rmin) 

329 d = min(d, rmax) 

330 b = max(b, a + 1e-6) 

331 cr = min(cr, d - 1e-6) 

332 if not (a < b <= cr < d): 

333 eps = 1e-6 

334 a, b, cr, d = c - 2 * eps, c - eps, c + eps, c + 2 * eps 

335 trapezoidal_mfs.append(TrapezoidalMF(a, b, cr, d)) 

336 return trapezoidal_mfs 

337 if key in {"sigmoidal", "sigmoid"}: 

338 sigmoidal_mfs: list[MembershipFunction] = [] 

339 for c, w in zip(centers, widths, strict=False): 

340 a = 4.4 / max(float(w), 1e-8) 

341 sigmoidal_mfs.append(SigmoidalMF(a=float(a), c=float(c))) 

342 return sigmoidal_mfs 

343 if key in {"linsshape", "ls"}: 

344 lin_s_mfs: list[MembershipFunction] = [] 

345 for c, w in zip(centers, widths, strict=False): 

346 a = float(max(c - w / 2.0, rmin)) 

347 b = float(min(c + w / 2.0, rmax)) 

348 if a >= b: 

349 eps = 1e-6 

350 a, b = c - eps, c + eps 

351 lin_s_mfs.append(LinSShapedMF(a, b)) 

352 return lin_s_mfs 

353 if key in {"linzshape", "lz"}: 

354 lin_z_mfs: list[MembershipFunction] = [] 

355 for c, w in zip(centers, widths, strict=False): 

356 a = float(max(c - w / 2.0, rmin)) 

357 b = float(min(c + w / 2.0, rmax)) 

358 if a >= b: 

359 eps = 1e-6 

360 a, b = c - eps, c + eps 

361 lin_z_mfs.append(LinZShapedMF(a, b)) 

362 return lin_z_mfs 

363 if key in {"diffsigmoidal", "diffsigmoid"}: 

364 diff_sig_mfs: list[MembershipFunction] = [] 

365 for c, w in zip(centers, widths, strict=False): 

366 c1 = float(max(c - w / 2.0, rmin)) 

367 c2 = float(min(c + w / 2.0, rmax)) 

368 if c1 >= c2: 

369 eps = 1e-6 

370 c1, c2 = c - eps, c + eps 

371 a = 4.4 / max(float(w), 1e-8) 

372 diff_sig_mfs.append(DiffSigmoidalMF(a1=float(a), c1=c1, a2=float(a), c2=c2)) 

373 return diff_sig_mfs 

374 if key in {"prodsigmoidal", "prodsigmoid"}: 

375 prod_sig_mfs: list[MembershipFunction] = [] 

376 for c, w in zip(centers, widths, strict=False): 

377 c1 = float(max(c - w / 2.0, rmin)) 

378 c2 = float(min(c + w / 2.0, rmax)) 

379 if c1 >= c2: 

380 eps = 1e-6 

381 c1, c2 = c - eps, c + eps 

382 a = 4.4 / max(float(w), 1e-8) 

383 prod_sig_mfs.append(ProdSigmoidalMF(a1=float(a), c1=c1, a2=float(-a), c2=c2)) 

384 return prod_sig_mfs 

385 if key in {"sshape", "s"}: 

386 s_shape_mfs: list[MembershipFunction] = [] 

387 for c, w in zip(centers, widths, strict=False): 

388 a = float(max(c - w / 2.0, rmin)) 

389 b = float(min(c + w / 2.0, rmax)) 

390 if a >= b: 

391 eps = 1e-6 

392 a, b = c - eps, c + eps 

393 s_shape_mfs.append(SShapedMF(a, b)) 

394 return s_shape_mfs 

395 if key in {"zshape", "z"}: 

396 z_shape_mfs: list[MembershipFunction] = [] 

397 for c, w in zip(centers, widths, strict=False): 

398 a = float(max(c - w / 2.0, rmin)) 

399 b = float(min(c + w / 2.0, rmax)) 

400 if a >= b: 

401 eps = 1e-6 

402 a, b = c - eps, c + eps 

403 z_shape_mfs.append(ZShapedMF(a, b)) 

404 return z_shape_mfs 

405 if key in {"pi", "pimf"}: 

406 pi_mfs: list[MembershipFunction] = [] 

407 plateau_frac = 0.3 

408 for c, w in zip(centers, widths, strict=False): 

409 a = float(c - w / 2.0) 

410 d = float(c + w / 2.0) 

411 b = float(a + (w * (1 - plateau_frac)) / 2.0) 

412 cr = float(b + w * plateau_frac) 

413 a = max(a, rmin) 

414 d = min(d, rmax) 

415 b = max(b, a + 1e-6) 

416 cr = min(cr, d - 1e-6) 

417 if not (a < b <= cr < d): 

418 eps = 1e-6 

419 a, b, cr, d = c - 2 * eps, c - eps, c + eps, c + 2 * eps 

420 pi_mfs.append(PiMF(a, b, cr, d)) 

421 return pi_mfs 

422 supported = ( 

423 "gaussian, gaussian2, bell/gbell, triangular, trapezoidal, sigmoidal/sigmoid, sshape/s, zshape/z, pi/pimf" 

424 ) 

425 raise ValueError(f"Initialization supports: {supported}") 

426 

427 def _create_gaussian_mfs(self, range_min: float, range_max: float, n_mfs: int, overlap: float) -> list[GaussianMF]: 

428 """Create evenly spaced Gaussian membership functions.""" 

429 centers = np.linspace(range_min, range_max, n_mfs) 

430 

431 # Handle single MF case 

432 if n_mfs == 1: 

433 sigma = (range_max - range_min) * 0.25 # Use quarter of range as default sigma 

434 else: 

435 sigma = (range_max - range_min) / (n_mfs - 1) * overlap 

436 

437 return [GaussianMF(mean=center, sigma=sigma) for center in centers] 

438 

439 def _create_gaussian2_mfs( 

440 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

441 ) -> list[Gaussian2MF]: 

442 """Create evenly spaced two-sided Gaussian (Gaussian2) membership functions. 

443 

444 Uses Gaussian tails with a small central plateau per MF. The plateau width 

445 is a fraction of the MF span controlled by overlap. 

446 """ 

447 centers = np.linspace(range_min, range_max, n_mfs) 

448 

449 # Determine spacing and widths 

450 if n_mfs == 1: 

451 spacing = range_max - range_min 

452 sigma = spacing * 0.25 

453 width = spacing * 0.5 

454 else: 

455 spacing = (range_max - range_min) / (n_mfs - 1) 

456 sigma = spacing * overlap 

457 width = spacing * (1 + overlap) 

458 

459 plateau_frac = 0.3 

460 half_plateau = (width * plateau_frac) / 2.0 

461 

462 mfs: list[Gaussian2MF] = [] 

463 for c in centers: 

464 c1 = float(max(c - half_plateau, range_min)) 

465 c2 = float(min(c + half_plateau, range_max)) 

466 if not (c1 < c2): 

467 eps = 1e-6 

468 c1, c2 = c - eps, c + eps 

469 mfs.append(Gaussian2MF(sigma1=float(sigma), c1=c1, sigma2=float(sigma), c2=c2)) 

470 return mfs 

471 

472 def _create_triangular_mfs( 

473 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

474 ) -> list[TriangularMF]: 

475 """Create evenly spaced triangular membership functions.""" 

476 centers = np.linspace(range_min, range_max, n_mfs) 

477 width = (range_max - range_min) / (n_mfs - 1) * (1 + overlap) 

478 

479 mfs: list[TriangularMF] = [] 

480 for i, center in enumerate(centers): 

481 a = center - width / 2 

482 b = center 

483 c = center + width / 2 

484 

485 # Adjust boundaries for edge cases 

486 if i == 0: 

487 a = range_min 

488 if i == n_mfs - 1: 

489 c = range_max 

490 

491 mfs.append(TriangularMF(a, b, c)) 

492 

493 return mfs 

494 

495 def _create_trapezoidal_mfs( 

496 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

497 ) -> list[TrapezoidalMF]: 

498 """Create evenly spaced trapezoidal membership functions.""" 

499 centers = np.linspace(range_min, range_max, n_mfs) 

500 width = (range_max - range_min) / (n_mfs - 1) * (1 + overlap) 

501 plateau = width * 0.3 # 30% plateau 

502 

503 mfs: list[TrapezoidalMF] = [] 

504 for i, center in enumerate(centers): 

505 a = center - width / 2 

506 b = center - plateau / 2 

507 c = center + plateau / 2 

508 d = center + width / 2 

509 

510 # Adjust boundaries for edge cases 

511 if i == 0: 

512 a = range_min 

513 b = max(b, range_min) 

514 if i == n_mfs - 1: 

515 c = min(c, range_max) 

516 d = range_max 

517 

518 mfs.append(TrapezoidalMF(a, b, c, d)) 

519 

520 return mfs 

521 

522 # New MF families 

523 def _create_bell_mfs(self, range_min: float, range_max: float, n_mfs: int, overlap: float) -> list[BellMF]: 

524 """Create evenly spaced Bell membership functions (generalized bell).""" 

525 centers = np.linspace(range_min, range_max, n_mfs) 

526 if n_mfs == 1: 

527 a = (range_max - range_min) * 0.25 

528 else: 

529 spacing = (range_max - range_min) / (n_mfs - 1) 

530 a = spacing * (1 + overlap) / 2.0 # half-width 

531 b = 2.0 # default slope 

532 return [BellMF(a=a, b=b, c=float(c)) for c in centers] 

533 

534 def _create_sigmoidal_mfs( 

535 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

536 ) -> list[SigmoidalMF]: 

537 """Create a bank of sigmoids across the range with centers and slopes.""" 

538 centers = np.linspace(range_min, range_max, n_mfs) 

539 if n_mfs == 1: 

540 width = (range_max - range_min) * 0.5 

541 else: 

542 spacing = (range_max - range_min) / (n_mfs - 1) 

543 width = spacing * (1 + overlap) 

544 # Choose slope a s.t. 0.1->0.9 transition ~ width: width ≈ 4.4 / a 

545 a = 4.4 / max(width, 1e-8) 

546 return [SigmoidalMF(a=float(a), c=float(c)) for c in centers] 

547 

548 def _create_linsshape_mfs( 

549 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

550 ) -> list[LinSShapedMF]: 

551 """Create linear S-shaped MFs across the range.""" 

552 centers = np.linspace(range_min, range_max, n_mfs) 

553 if n_mfs == 1: 

554 width = (range_max - range_min) * 0.5 

555 else: 

556 spacing = (range_max - range_min) / (n_mfs - 1) 

557 width = spacing * (1 + overlap) 

558 half = width / 2.0 

559 mfs: list[LinSShapedMF] = [] 

560 for c in centers: 

561 a = float(max(c - half, range_min)) 

562 b = float(min(c + half, range_max)) 

563 if a >= b: 

564 eps = 1e-6 

565 a, b = c - eps, c + eps 

566 mfs.append(LinSShapedMF(a, b)) 

567 return mfs 

568 

569 def _create_linzshape_mfs( 

570 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

571 ) -> list[LinZShapedMF]: 

572 """Create linear Z-shaped MFs across the range.""" 

573 centers = np.linspace(range_min, range_max, n_mfs) 

574 if n_mfs == 1: 

575 width = (range_max - range_min) * 0.5 

576 else: 

577 spacing = (range_max - range_min) / (n_mfs - 1) 

578 width = spacing * (1 + overlap) 

579 half = width / 2.0 

580 mfs: list[LinZShapedMF] = [] 

581 for c in centers: 

582 a = float(max(c - half, range_min)) 

583 b = float(min(c + half, range_max)) 

584 if a >= b: 

585 eps = 1e-6 

586 a, b = c - eps, c + eps 

587 mfs.append(LinZShapedMF(a, b)) 

588 return mfs 

589 

590 def _create_diff_sigmoidal_mfs( 

591 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

592 ) -> list[DiffSigmoidalMF]: 

593 """Create bands using difference of two sigmoids around evenly spaced centers.""" 

594 centers = np.linspace(range_min, range_max, n_mfs) 

595 if n_mfs == 1: 

596 width = (range_max - range_min) * 0.5 

597 else: 

598 spacing = (range_max - range_min) / (n_mfs - 1) 

599 width = spacing * (1 + overlap) 

600 mfs: list[DiffSigmoidalMF] = [] 

601 for c in centers: 

602 c1 = float(max(c - width / 2.0, range_min)) 

603 c2 = float(min(c + width / 2.0, range_max)) 

604 if c1 >= c2: 

605 eps = 1e-6 

606 c1, c2 = c - eps, c + eps 

607 a = 4.4 / max(float(width), 1e-8) 

608 mfs.append(DiffSigmoidalMF(a1=float(a), c1=c1, a2=float(a), c2=c2)) 

609 return mfs 

610 

611 def _create_prod_sigmoidal_mfs( 

612 self, range_min: float, range_max: float, n_mfs: int, overlap: float 

613 ) -> list[ProdSigmoidalMF]: 

614 """Create product-of-sigmoids MFs; use increasing and decreasing pair to form a bump.""" 

615 centers = np.linspace(range_min, range_max, n_mfs) 

616 if n_mfs == 1: 

617 width = (range_max - range_min) * 0.5 

618 else: 

619 spacing = (range_max - range_min) / (n_mfs - 1) 

620 width = spacing * (1 + overlap) 

621 mfs: list[ProdSigmoidalMF] = [] 

622 for c in centers: 

623 c1 = float(max(c - width / 2.0, range_min)) 

624 c2 = float(min(c + width / 2.0, range_max)) 

625 if c1 >= c2: 

626 eps = 1e-6 

627 c1, c2 = c - eps, c + eps 

628 a = 4.4 / max(float(width), 1e-8) 

629 mfs.append(ProdSigmoidalMF(a1=float(a), c1=c1, a2=float(-a), c2=c2)) 

630 return mfs 

631 

632 def _create_sshape_mfs(self, range_min: float, range_max: float, n_mfs: int, overlap: float) -> list[SShapedMF]: 

633 """Create S-shaped MFs with spans around evenly spaced centers.""" 

634 centers = np.linspace(range_min, range_max, n_mfs) 

635 if n_mfs == 1: 

636 width = (range_max - range_min) * 0.5 

637 else: 

638 spacing = (range_max - range_min) / (n_mfs - 1) 

639 width = spacing * (1 + overlap) 

640 half = width / 2.0 

641 mfs: list[SShapedMF] = [] 

642 for c in centers: 

643 a = float(c - half) 

644 b = float(c + half) 

645 # Clamp to the provided range 

646 a = max(a, range_min) 

647 b = min(b, range_max) 

648 if a >= b: 

649 # Fallback to a tiny span 

650 eps = 1e-6 

651 a, b = float(c - eps), float(c + eps) 

652 mfs.append(SShapedMF(a, b)) 

653 return mfs 

654 

655 def _create_zshape_mfs(self, range_min: float, range_max: float, n_mfs: int, overlap: float) -> list[ZShapedMF]: 

656 """Create Z-shaped MFs with spans around evenly spaced centers.""" 

657 centers = np.linspace(range_min, range_max, n_mfs) 

658 if n_mfs == 1: 

659 width = (range_max - range_min) * 0.5 

660 else: 

661 spacing = (range_max - range_min) / (n_mfs - 1) 

662 width = spacing * (1 + overlap) 

663 half = width / 2.0 

664 mfs: list[ZShapedMF] = [] 

665 for c in centers: 

666 a = float(c - half) 

667 b = float(c + half) 

668 a = max(a, range_min) 

669 b = min(b, range_max) 

670 if a >= b: 

671 eps = 1e-6 

672 a, b = float(c - eps), float(c + eps) 

673 mfs.append(ZShapedMF(a, b)) 

674 return mfs 

675 

676 def _create_pi_mfs(self, range_min: float, range_max: float, n_mfs: int, overlap: float) -> list[PiMF]: 

677 """Create Pi-shaped MFs with smooth S/Z edges and a flat top.""" 

678 centers = np.linspace(range_min, range_max, n_mfs) 

679 if n_mfs == 1: 

680 width = (range_max - range_min) * 0.5 

681 else: 

682 spacing = (range_max - range_min) / (n_mfs - 1) 

683 width = spacing * (1 + overlap) 

684 plateau = width * 0.3 

685 mfs: list[PiMF] = [] 

686 for c in centers: 

687 a = float(c - width / 2.0) 

688 d = float(c + width / 2.0) 

689 b = float(a + (width - plateau) / 2.0) 

690 c_right = float(b + plateau) 

691 # Clamp within the provided range 

692 a = max(a, range_min) 

693 d = min(d, range_max) 

694 # Ensure ordering a < b ≤ c < d 

695 b = max(b, a + 1e-6) 

696 c_right = min(c_right, d - 1e-6) 

697 if not (a < b <= c_right < d): 

698 # Fallback to a minimal valid shape around center 

699 eps = 1e-6 

700 a, b, c_right, d = c - 2 * eps, c - eps, c + eps, c + 2 * eps 

701 mfs.append(PiMF(a, b, c_right, d)) 

702 return mfs 

703 

704 def build(self) -> TSKANFIS: 

705 """Build the ANFIS model with configured parameters.""" 

706 if not self.input_mfs: 

707 raise ValueError("No input variables defined. Use add_input() to define inputs.") 

708 

709 return TSKANFIS(self.input_mfs, rules=self._rules)