Skip to content

Commit f2d5d1e

Browse files
committed
implement singleton sets to take their special needs into account
1 parent a8dcfb8 commit f2d5d1e

File tree

3 files changed

+192
-13
lines changed

3 files changed

+192
-13
lines changed

src/fuzzylogic/classes.py

+41
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,47 @@ def __hash__(self) -> int:
438438
return id(self)
439439

440440

441+
class SingletonSet(Set):
442+
def __init__(self, c: float, no_m: float = 0, c_m: float = 1, domain: Domain | None = None):
443+
super().__init__(self._singleton_fn(c, no_m, c_m), domain=domain)
444+
self.c = c
445+
self.no_m = no_m
446+
self.c_m = c_m
447+
self.domain = domain
448+
449+
self._cached_array: np.ndarray | None = None
450+
451+
@staticmethod
452+
def _singleton_fn(c: float, no_m: float = 0, c_m: float = 1) -> Membership:
453+
return lambda x: c_m if x == c else no_m
454+
455+
def center_of_gravity(self) -> float:
456+
"""Directly return singleton position"""
457+
return self.c
458+
459+
def plot(self) -> None:
460+
"""Graph the singleton set in the given domain,
461+
ensuring that the singleton's coordinate is included.
462+
"""
463+
assert self.domain is not None, "NO_DOMAIN"
464+
if not plt:
465+
raise ImportError(
466+
"matplotlib not available. Please re-install with 'pip install fuzzylogic[plotting]'"
467+
)
468+
469+
R = self.domain.range
470+
if self.c not in R:
471+
R = sorted(set(R).union({self.c}))
472+
V = [self.func(x) for x in R]
473+
plt.plot(R, V, label=f"Singleton {self.c}")
474+
plt.title("Singleton Membership Function")
475+
plt.xlabel("Domain Value")
476+
plt.ylabel("Membership")
477+
plt.legend()
478+
plt.grid(True)
479+
plt.show()
480+
481+
441482
class Rule:
442483
"""
443484
Collection of bound sets spanning a multi-dimensional space of their domains, mapping to a target domain.

src/fuzzylogic/functions.py

+18-13
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,18 @@
2626
In a fuzzy set with one and only one m == 1, this element is called 'prototype'.
2727
"""
2828

29+
from __future__ import annotations
30+
2931
from collections.abc import Callable
3032
from math import exp, isinf, isnan, log
31-
from typing import Any
33+
from typing import TYPE_CHECKING, Any
34+
35+
if TYPE_CHECKING:
36+
from .classes import SingletonSet
3237

3338
type Membership = Callable[[float], float]
3439

40+
3541
try:
3642
# from numba import njit # still not ready for prime time :(
3743
raise ImportError
@@ -146,21 +152,20 @@ def f(x: float) -> float:
146152
########################
147153

148154

149-
def singleton(p: float, *, no_m: float = 0, c_m: float = 1) -> Membership:
150-
"""A single spike.
155+
def singleton(c: float, no_m: float = 0, c_m: float = 1) -> SingletonSet:
156+
"""A singleton function.
151157
152-
>>> f = singleton(2)
153-
>>> f(1)
154-
0
155-
>>> f(2)
156-
1
157-
"""
158-
assert 0 <= no_m < c_m <= 1
158+
This is unusually tricky because the CoG sums up all values and divides by the number of values, which
159+
may result in 0 due to rounding errors.
160+
Additionally and more significantly, a singleton well within domain range but not within
161+
its resolution will never be found and considered. Thus, singletons need special treatment.
159162
160-
def f(x: float) -> float:
161-
return c_m if x == p else no_m
163+
We solve this issue by returning a special subclass (which must be imported here due to circular import),
164+
which overrides the normal CoG implementation, but still works with the rest of the code.
165+
"""
166+
from .classes import SingletonSet
162167

163-
return f
168+
return SingletonSet(c, no_m=no_m, c_m=c_m)
164169

165170

166171
def linear(m: float = 0, b: float = 0) -> Membership:

tests/test_singleton.py

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from __future__ import annotations
2+
3+
import math
4+
5+
import numpy as np
6+
import pytest
7+
from hypothesis import given
8+
from hypothesis import strategies as st
9+
10+
from fuzzylogic.classes import Domain
11+
from fuzzylogic.functions import singleton
12+
13+
# ---------------------------------------------------------------------------
14+
# Basic Unit Tests
15+
# ---------------------------------------------------------------------------
16+
17+
18+
def test_singleton_membership():
19+
"""Test that a singleton returns 1.0 exactly at its specified location and 0 elsewhere."""
20+
s = singleton(500)
21+
# Exact hit yields 1.0
22+
assert s(500) == 1.0
23+
# Any other value yields 0.0
24+
assert s(499.999) == 0.0
25+
assert s(500.1) == 0.0
26+
27+
28+
def test_singleton_center_of_gravity():
29+
"""Test that the center_of_gravity always returns the singleton’s location."""
30+
for c in [0, 250, 500, 750, 1000]:
31+
s = singleton(c)
32+
assert s.center_of_gravity() == c, f"Expected COG {c}, got {s.center_of_gravity()}"
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Domain Integration Tests
37+
# ---------------------------------------------------------------------------
38+
39+
40+
def test_singleton_with_domain():
41+
"""
42+
Test that a SingletonSet assigned to a domain yields the expected membership
43+
array, containing a spike at the correct position.
44+
"""
45+
D = Domain("D", 0, 1000)
46+
s = singleton(500)
47+
s.domain = D
48+
49+
arr = s.array()
50+
points = D.range
51+
52+
assert 500 in points, "Domain should contain 500 exactly."
53+
idx = points[500]
54+
55+
np.testing.assert_almost_equal(arr[idx], 1.0)
56+
np.testing.assert_almost_equal(arr.sum(), 1.0)
57+
58+
59+
@given(c=st.integers(min_value=0, max_value=1000))
60+
def test_singleton_property_center(c: int):
61+
"""
62+
Property-based test: For any integer c in [0, 1000], a singleton defined at c
63+
(and assigned to an appropriately discretized Domain) has a center-of-gravity equal to c.
64+
"""
65+
D = Domain("D", 0, 1000)
66+
s = singleton(c)
67+
s.domain = D
68+
assert s.center_of_gravity() == c
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Fuzzy Operation Integration Tests
73+
# ---------------------------------------------------------------------------
74+
75+
76+
def test_singleton_union():
77+
"""
78+
Test that the fuzzy union (OR) of two disjoint singleton sets creates a fuzzy set
79+
containing two spikes – one at each singleton location.
80+
"""
81+
D = Domain("D", 0, 1000)
82+
s1 = singleton(500)
83+
s2 = singleton(600)
84+
s1.domain = D
85+
s2.domain = D
86+
87+
union_set = s1 | s2
88+
union_set.domain = D
89+
90+
arr = union_set.array()
91+
points = D.range
92+
93+
assert 500 in points and 600 in points
94+
idx_500 = points[500]
95+
idx_600 = points[600]
96+
np.testing.assert_almost_equal(arr[idx_500], 1.0)
97+
np.testing.assert_almost_equal(arr[idx_600], 1.0)
98+
99+
np.testing.assert_almost_equal(arr.sum(), 2.0)
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# Differential / Regression Testing with Defuzzification
104+
# ---------------------------------------------------------------------------
105+
106+
107+
def test_singleton_defuzzification():
108+
"""
109+
Test that when a singleton is used in defuzzification (via center_of_gravity),
110+
the exact spike value is returned regardless of discrete sampling issues.
111+
"""
112+
s = singleton(500.1)
113+
assert math.isclose(s.center_of_gravity(), 500.1, rel_tol=1e-9)
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# Performance
118+
# ---------------------------------------------------------------------------
119+
120+
121+
def test_singleton_performance():
122+
"""
123+
A basic performance test to ensure that evaluating a singleton over a large domain
124+
remains efficient.
125+
"""
126+
D = Domain("D", 0, 1000, res=0.0001) # A large domain
127+
D.s = singleton(500)
128+
time_taken = pytest.importorskip("timeit").timeit(lambda: D.s.array(), number=10)
129+
assert time_taken < 1, "Performance slowed down unexpectedly."
130+
131+
132+
if __name__ == "__main__":
133+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)