findermiss 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 NOPPARAT CHEUNARROM
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,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: findermiss
3
+ Version: 0.1.0
4
+ Summary: Fill missing values in time series (linear / harmonic / snap)
5
+ Author: NOPPARAT CHEUNARROM
6
+ License-Expression: MIT
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pandas>=2.0
11
+ Provides-Extra: plot
12
+ Requires-Dist: matplotlib; extra == "plot"
13
+ Dynamic: license-file
14
+
15
+ # findermiss
16
+
17
+ Fill missing values in time series. For each gap, `findermiss` picks between straight-line
18
+ interpolation and a fitted daily-cycle wave, and can snap the result to the step size of
19
+ stepped (quantised) sensor data.
20
+
21
+ ## Why this exists
22
+
23
+ `findermiss` started with a plain problem: we wanted to analyse readings from monitoring
24
+ stations, but some stretches of data were missing, and the analysis needed a complete series.
25
+
26
+ Short gaps were easy, a straight line between the neighbouring readings was good enough.
27
+ Long gaps were not. One station lost about 38 hours of a signal that rises and falls every
28
+ day, and linear interpolation drew a flat line straight through about one and a half cycles
29
+ (R² = −0.74 against the real values, worse than just using the average). Fitting the
30
+ station's own daily wave from the data around the gap and joining it to both edges brought
31
+ that up to R² = 0.72.
32
+
33
+ Other stations reported in fixed steps (e.g. 0.01), so filled values that land between
34
+ steps looked fake. Rounding them to the step size made the fill match the real readings
35
+ exactly at most points.
36
+
37
+ `findermiss` puts those three ideas together and decides gap by gap which one to use.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ pip install findermiss # numpy, pandas
43
+ pip install findermiss[plot] # + matplotlib, for FinderMiss.plot()
44
+ ```
45
+
46
+ Requires pandas 2.0 or newer.
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ from findermiss import FinderMiss
52
+
53
+ fm = FinderMiss.from_csv("station_missing.csv", time_col="datetime", value_col="value")
54
+ filled = fm.fill() # "auto": chooses a method for each gap
55
+ filled.to_csv("station_filled.csv", index=False)
56
+ ```
57
+
58
+ Missing values must be empty / `NaN` in the value column. `fill()` returns a copy of your
59
+ data with the gaps filled and a `status` column telling where each value came from:
60
+
61
+ | status | meaning |
62
+ |---|---|
63
+ | `real` | original value, untouched |
64
+ | `imputed_linear` | straight line between the values on both sides of the gap |
65
+ | `imputed_harmonic` | fitted wave (dominant period + its half) with trend, joined smoothly to both sides |
66
+ | `..._snap` suffix | rounded to the data's step size |
67
+
68
+ ## Methods
69
+
70
+ ```python
71
+ fm.fit() # optional; runs automatically on first use
72
+ print(fm) # FinderMiss(6455 rows, time=real, grid=0.009999999999999787, period=25.0h, gaps=1, missing=287)
73
+ fm.methods # methods usable on this data, e.g. ['linear', 'linear+snap', 'harmonic', 'harmonic+snap']
74
+ fm.fill("harmonic+snap")
75
+ ```
76
+
77
+ - **linear** is always available.
78
+ - **harmonic** is available when the timestamps are real dates covering more than 2 days
79
+ and a clear cycle between 6 and 48 hours is found near the gaps.
80
+ - **+snap** is available when nearly all values are multiples of one step size (stepped data).
81
+ - **auto** (default) uses harmonic for gaps longer than `long_gap_h` (6 hours) when a cycle
82
+ was found, linear otherwise, and snaps whenever a step size was found.
83
+
84
+ Timestamps can be full dates, or `mm:ss` only — in that case time is rebuilt by counting
85
+ each time the minutes wrap back to zero as one hour.
86
+
87
+ ## Measuring accuracy
88
+
89
+ If you have the complete data (e.g. you removed values on purpose to test), compare every
90
+ method on the gap points only:
91
+
92
+ ```python
93
+ fm.evaluate("station_complete.csv") # path, DataFrame or array, same row order
94
+ # method n MAE RMSE R2 exact
95
+ # auto 287 0.1430 0.1902 0.7193 2.4390
96
+ # linear 287 0.3934 0.4741 -0.7441 0.0000
97
+ # ...
98
+ ```
99
+
100
+ `exact` is the percentage of gap points filled with the right value (within floating-point
101
+ tolerance, `numpy.isclose`).
102
+
103
+ ## Plot
104
+
105
+ ```python
106
+ fm.plot(truth="station_complete.csv", save="compare.png", show=False)
107
+ ```
108
+
109
+ One panel per method, zoomed in around the gaps. Needs `findermiss[plot]`.
110
+
111
+ ## Options
112
+
113
+ ```python
114
+ FinderMiss(df, time_col="datetime", value_col="value",
115
+ time=None, # use these timestamps instead of df[time_col]
116
+ long_gap_h=6.0, # auto: gaps longer than this (hours) use harmonic
117
+ period_band_h=(6, 48)) # cycle lengths (hours) to look for
118
+ ```
119
+
120
+ ## Known limitations
121
+
122
+ - Fully duplicated rows are not merged; they are filled and scored as separate points.
123
+ - Timestamps that cannot be parsed become `NaN` and are not reported.
124
+ - A gap at the very start or end of the data cannot be joined to both sides, so linear
125
+ repeats the nearest value and harmonic is not pulled back towards the data.
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,115 @@
1
+ # findermiss
2
+
3
+ Fill missing values in time series. For each gap, `findermiss` picks between straight-line
4
+ interpolation and a fitted daily-cycle wave, and can snap the result to the step size of
5
+ stepped (quantised) sensor data.
6
+
7
+ ## Why this exists
8
+
9
+ `findermiss` started with a plain problem: we wanted to analyse readings from monitoring
10
+ stations, but some stretches of data were missing, and the analysis needed a complete series.
11
+
12
+ Short gaps were easy, a straight line between the neighbouring readings was good enough.
13
+ Long gaps were not. One station lost about 38 hours of a signal that rises and falls every
14
+ day, and linear interpolation drew a flat line straight through about one and a half cycles
15
+ (R² = −0.74 against the real values, worse than just using the average). Fitting the
16
+ station's own daily wave from the data around the gap and joining it to both edges brought
17
+ that up to R² = 0.72.
18
+
19
+ Other stations reported in fixed steps (e.g. 0.01), so filled values that land between
20
+ steps looked fake. Rounding them to the step size made the fill match the real readings
21
+ exactly at most points.
22
+
23
+ `findermiss` puts those three ideas together and decides gap by gap which one to use.
24
+
25
+ ## Install
26
+
27
+ ```
28
+ pip install findermiss # numpy, pandas
29
+ pip install findermiss[plot] # + matplotlib, for FinderMiss.plot()
30
+ ```
31
+
32
+ Requires pandas 2.0 or newer.
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ from findermiss import FinderMiss
38
+
39
+ fm = FinderMiss.from_csv("station_missing.csv", time_col="datetime", value_col="value")
40
+ filled = fm.fill() # "auto": chooses a method for each gap
41
+ filled.to_csv("station_filled.csv", index=False)
42
+ ```
43
+
44
+ Missing values must be empty / `NaN` in the value column. `fill()` returns a copy of your
45
+ data with the gaps filled and a `status` column telling where each value came from:
46
+
47
+ | status | meaning |
48
+ |---|---|
49
+ | `real` | original value, untouched |
50
+ | `imputed_linear` | straight line between the values on both sides of the gap |
51
+ | `imputed_harmonic` | fitted wave (dominant period + its half) with trend, joined smoothly to both sides |
52
+ | `..._snap` suffix | rounded to the data's step size |
53
+
54
+ ## Methods
55
+
56
+ ```python
57
+ fm.fit() # optional; runs automatically on first use
58
+ print(fm) # FinderMiss(6455 rows, time=real, grid=0.009999999999999787, period=25.0h, gaps=1, missing=287)
59
+ fm.methods # methods usable on this data, e.g. ['linear', 'linear+snap', 'harmonic', 'harmonic+snap']
60
+ fm.fill("harmonic+snap")
61
+ ```
62
+
63
+ - **linear** is always available.
64
+ - **harmonic** is available when the timestamps are real dates covering more than 2 days
65
+ and a clear cycle between 6 and 48 hours is found near the gaps.
66
+ - **+snap** is available when nearly all values are multiples of one step size (stepped data).
67
+ - **auto** (default) uses harmonic for gaps longer than `long_gap_h` (6 hours) when a cycle
68
+ was found, linear otherwise, and snaps whenever a step size was found.
69
+
70
+ Timestamps can be full dates, or `mm:ss` only — in that case time is rebuilt by counting
71
+ each time the minutes wrap back to zero as one hour.
72
+
73
+ ## Measuring accuracy
74
+
75
+ If you have the complete data (e.g. you removed values on purpose to test), compare every
76
+ method on the gap points only:
77
+
78
+ ```python
79
+ fm.evaluate("station_complete.csv") # path, DataFrame or array, same row order
80
+ # method n MAE RMSE R2 exact
81
+ # auto 287 0.1430 0.1902 0.7193 2.4390
82
+ # linear 287 0.3934 0.4741 -0.7441 0.0000
83
+ # ...
84
+ ```
85
+
86
+ `exact` is the percentage of gap points filled with the right value (within floating-point
87
+ tolerance, `numpy.isclose`).
88
+
89
+ ## Plot
90
+
91
+ ```python
92
+ fm.plot(truth="station_complete.csv", save="compare.png", show=False)
93
+ ```
94
+
95
+ One panel per method, zoomed in around the gaps. Needs `findermiss[plot]`.
96
+
97
+ ## Options
98
+
99
+ ```python
100
+ FinderMiss(df, time_col="datetime", value_col="value",
101
+ time=None, # use these timestamps instead of df[time_col]
102
+ long_gap_h=6.0, # auto: gaps longer than this (hours) use harmonic
103
+ period_band_h=(6, 48)) # cycle lengths (hours) to look for
104
+ ```
105
+
106
+ ## Known limitations
107
+
108
+ - Fully duplicated rows are not merged; they are filled and scored as separate points.
109
+ - Timestamps that cannot be parsed become `NaN` and are not reported.
110
+ - A gap at the very start or end of the data cannot be joined to both sides, so linear
111
+ repeats the nearest value and harmonic is not pulled back towards the data.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: findermiss
3
+ Version: 0.1.0
4
+ Summary: Fill missing values in time series (linear / harmonic / snap)
5
+ Author: NOPPARAT CHEUNARROM
6
+ License-Expression: MIT
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pandas>=2.0
11
+ Provides-Extra: plot
12
+ Requires-Dist: matplotlib; extra == "plot"
13
+ Dynamic: license-file
14
+
15
+ # findermiss
16
+
17
+ Fill missing values in time series. For each gap, `findermiss` picks between straight-line
18
+ interpolation and a fitted daily-cycle wave, and can snap the result to the step size of
19
+ stepped (quantised) sensor data.
20
+
21
+ ## Why this exists
22
+
23
+ `findermiss` started with a plain problem: we wanted to analyse readings from monitoring
24
+ stations, but some stretches of data were missing, and the analysis needed a complete series.
25
+
26
+ Short gaps were easy, a straight line between the neighbouring readings was good enough.
27
+ Long gaps were not. One station lost about 38 hours of a signal that rises and falls every
28
+ day, and linear interpolation drew a flat line straight through about one and a half cycles
29
+ (R² = −0.74 against the real values, worse than just using the average). Fitting the
30
+ station's own daily wave from the data around the gap and joining it to both edges brought
31
+ that up to R² = 0.72.
32
+
33
+ Other stations reported in fixed steps (e.g. 0.01), so filled values that land between
34
+ steps looked fake. Rounding them to the step size made the fill match the real readings
35
+ exactly at most points.
36
+
37
+ `findermiss` puts those three ideas together and decides gap by gap which one to use.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ pip install findermiss # numpy, pandas
43
+ pip install findermiss[plot] # + matplotlib, for FinderMiss.plot()
44
+ ```
45
+
46
+ Requires pandas 2.0 or newer.
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ from findermiss import FinderMiss
52
+
53
+ fm = FinderMiss.from_csv("station_missing.csv", time_col="datetime", value_col="value")
54
+ filled = fm.fill() # "auto": chooses a method for each gap
55
+ filled.to_csv("station_filled.csv", index=False)
56
+ ```
57
+
58
+ Missing values must be empty / `NaN` in the value column. `fill()` returns a copy of your
59
+ data with the gaps filled and a `status` column telling where each value came from:
60
+
61
+ | status | meaning |
62
+ |---|---|
63
+ | `real` | original value, untouched |
64
+ | `imputed_linear` | straight line between the values on both sides of the gap |
65
+ | `imputed_harmonic` | fitted wave (dominant period + its half) with trend, joined smoothly to both sides |
66
+ | `..._snap` suffix | rounded to the data's step size |
67
+
68
+ ## Methods
69
+
70
+ ```python
71
+ fm.fit() # optional; runs automatically on first use
72
+ print(fm) # FinderMiss(6455 rows, time=real, grid=0.009999999999999787, period=25.0h, gaps=1, missing=287)
73
+ fm.methods # methods usable on this data, e.g. ['linear', 'linear+snap', 'harmonic', 'harmonic+snap']
74
+ fm.fill("harmonic+snap")
75
+ ```
76
+
77
+ - **linear** is always available.
78
+ - **harmonic** is available when the timestamps are real dates covering more than 2 days
79
+ and a clear cycle between 6 and 48 hours is found near the gaps.
80
+ - **+snap** is available when nearly all values are multiples of one step size (stepped data).
81
+ - **auto** (default) uses harmonic for gaps longer than `long_gap_h` (6 hours) when a cycle
82
+ was found, linear otherwise, and snaps whenever a step size was found.
83
+
84
+ Timestamps can be full dates, or `mm:ss` only — in that case time is rebuilt by counting
85
+ each time the minutes wrap back to zero as one hour.
86
+
87
+ ## Measuring accuracy
88
+
89
+ If you have the complete data (e.g. you removed values on purpose to test), compare every
90
+ method on the gap points only:
91
+
92
+ ```python
93
+ fm.evaluate("station_complete.csv") # path, DataFrame or array, same row order
94
+ # method n MAE RMSE R2 exact
95
+ # auto 287 0.1430 0.1902 0.7193 2.4390
96
+ # linear 287 0.3934 0.4741 -0.7441 0.0000
97
+ # ...
98
+ ```
99
+
100
+ `exact` is the percentage of gap points filled with the right value (within floating-point
101
+ tolerance, `numpy.isclose`).
102
+
103
+ ## Plot
104
+
105
+ ```python
106
+ fm.plot(truth="station_complete.csv", save="compare.png", show=False)
107
+ ```
108
+
109
+ One panel per method, zoomed in around the gaps. Needs `findermiss[plot]`.
110
+
111
+ ## Options
112
+
113
+ ```python
114
+ FinderMiss(df, time_col="datetime", value_col="value",
115
+ time=None, # use these timestamps instead of df[time_col]
116
+ long_gap_h=6.0, # auto: gaps longer than this (hours) use harmonic
117
+ period_band_h=(6, 48)) # cycle lengths (hours) to look for
118
+ ```
119
+
120
+ ## Known limitations
121
+
122
+ - Fully duplicated rows are not merged; they are filled and scored as separate points.
123
+ - Timestamps that cannot be parsed become `NaN` and are not reported.
124
+ - A gap at the very start or end of the data cannot be joined to both sides, so linear
125
+ repeats the nearest value and harmonic is not pulled back towards the data.
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ findermiss.py
4
+ pyproject.toml
5
+ findermiss.egg-info/PKG-INFO
6
+ findermiss.egg-info/SOURCES.txt
7
+ findermiss.egg-info/dependency_links.txt
8
+ findermiss.egg-info/requires.txt
9
+ findermiss.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ numpy
2
+ pandas>=2.0
3
+
4
+ [plot]
5
+ matplotlib
@@ -0,0 +1 @@
1
+ findermiss
@@ -0,0 +1,292 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ DAY = 86400
7
+ HOUR = 3600
8
+
9
+ REAL_TIME_MIN_SPAN = pd.Timedelta("2D")
10
+ GRID_MIN_STEP = 1e-4
11
+ GRID_MIN_FIT_SHARE = 0.98 # ยอมให้ค่าราว 2% หลุดกริด (เช่นค่าผิดปกติ) โดยยังนับว่าเป็นข้อมูลขั้นบันได
12
+ PERIOD_SEARCH_HALF_WINDOW = 12 * DAY
13
+ PERIOD_MIN_POINTS = 50
14
+ PERIOD_RESAMPLE_STEP = 1800.0 # FFT ต้องการข้อมูลระยะห่างเท่ากัน
15
+ PERIOD_MIN_PEAK_TO_MEDIAN = 3
16
+ PLOT_MIN_PAD = 15
17
+
18
+
19
+ class FinderMiss:
20
+ def __init__(self, df, time_col="datetime", value_col="value", time=None,
21
+ long_gap_h=6.0, period_band_h=(6, 48)):
22
+ self.df = df.reset_index(drop=True)
23
+ self.time_col, self.value_col = time_col, value_col
24
+ self.time = self.df[time_col] if time is None else pd.Series(time).reset_index(drop=True)
25
+ if len(self.time) != len(self.df):
26
+ raise ValueError(f"time ยาว {len(self.time)} แถว แต่ df ยาว {len(self.df)} แถว")
27
+ self.long_gap_h = long_gap_h
28
+ self.period_band_h = period_band_h
29
+ self._fitted = False
30
+
31
+ @classmethod
32
+ def from_csv(cls, path, time_col="datetime", value_col="value", time=None, **kw):
33
+ return cls(pd.read_csv(path), time_col, value_col, time=time, **kw)
34
+
35
+ def __repr__(self):
36
+ if not self._fitted:
37
+ return f"FinderMiss({len(self.df)} rows, not fitted)"
38
+ period = f"{self.period_h:.1f}h" if self.period_h else None
39
+ return (f"FinderMiss({len(self.df)} rows, time={'real' if self.is_real_time else 'reconstructed'}, "
40
+ f"grid={self.grid}, period={period}, gaps={len(self.gaps)}, missing={int((~self.known).sum())})")
41
+
42
+ def fit(self):
43
+ self.time_s, self.is_real_time = _build_time(self.time)
44
+ self.y = self.df[self.value_col].to_numpy(float)
45
+ self.known = ~np.isnan(self.y)
46
+ self.gaps = _gap_runs(self.known)
47
+ self.grid = _detect_grid(self.y)
48
+ period_s = None
49
+ # เวลาที่สร้างจาก mm:ss อาจเพี้ยนไปทีละชั่วโมง คาบที่หาได้จึงเชื่อไม่ได้
50
+ if self.is_real_time and self.gaps:
51
+ gaps_center = self.time_s[~self.known].mean()
52
+ period_s = _detect_period(self.time_s, self.y, self.known, gaps_center, self.period_band_h)
53
+ self.period_h = period_s / HOUR if period_s else None
54
+ self._fitted = True
55
+ return self
56
+
57
+ def _ensure_fit(self):
58
+ if not self._fitted:
59
+ self.fit()
60
+
61
+ @property
62
+ def methods(self):
63
+ self._ensure_fit()
64
+ out = ["linear"]
65
+ if self.grid is not None:
66
+ out.append("linear+snap")
67
+ if self.period_h:
68
+ out.append("harmonic")
69
+ if self.grid is not None:
70
+ out.append("harmonic+snap")
71
+ return out
72
+
73
+ def fill(self, method="auto"):
74
+ filled, labels = self._fill_values(method)
75
+ out = self.df.copy()
76
+ out[self.value_col] = filled
77
+ out["status"] = labels
78
+ return out
79
+
80
+ def _fill_values(self, method):
81
+ self._ensure_fit()
82
+ if method != "auto" and method not in self.methods:
83
+ raise ValueError(f"method '{method}' ใช้ไม่ได้กับข้อมูลนี้ — ใช้ได้: {['auto'] + self.methods}")
84
+ filled = self.y.copy()
85
+ labels = np.full(len(self.y), "real", dtype=object)
86
+ if not self.gaps:
87
+ return filled, labels
88
+ linear = _linear(self.time_s, self.y, self.known)
89
+ for i0, i1 in self.gaps:
90
+ base, snap = self._gap_method(method, i0, i1)
91
+ gap = slice(i0, i1 + 1)
92
+ if base == "harmonic":
93
+ filled[gap] = _harmonic(self.time_s, self.y, self.known, i0, i1, self.period_h * HOUR)
94
+ else:
95
+ filled[gap] = linear[gap]
96
+ if snap:
97
+ filled[gap] = _snap(filled[gap], self.grid)
98
+ labels[gap] = f"imputed_{base}" + ("_snap" if snap else "")
99
+ return filled, labels
100
+
101
+ def _gap_method(self, method, i0, i1):
102
+ if method != "auto":
103
+ base, _, suffix = method.partition("+")
104
+ return base, suffix == "snap"
105
+ span_h = (self.time_s[i1] - self.time_s[i0]) / HOUR
106
+ # รูสั้น เส้นตรงก็พอ รูยาวเส้นตรงจะตัดผ่านรอบคลื่นที่หายไปทั้งรอบ
107
+ base = "harmonic" if (self.period_h and span_h > self.long_gap_h) else "linear"
108
+ return base, self.grid is not None
109
+
110
+ def evaluate(self, truth, methods=None):
111
+ self._ensure_fit()
112
+ actual = self._as_truth(truth)
113
+ scored = ~self.known & np.isfinite(actual)
114
+ if not scored.any():
115
+ raise ValueError("ไม่มีจุดในรูที่ truth มีค่าให้เทียบ")
116
+ actual = actual[scored]
117
+ total_variance = np.sum((actual - actual.mean()) ** 2)
118
+ rows = []
119
+ for method in methods or ["auto"] + self.methods:
120
+ predicted = self._fill_values(method)[0][scored]
121
+ error = predicted - actual
122
+ rows.append(dict(method=method, n=int(scored.sum()),
123
+ MAE=np.mean(np.abs(error)),
124
+ RMSE=np.sqrt(np.mean(error ** 2)),
125
+ R2=1 - np.sum(error ** 2) / total_variance if total_variance > 0 else np.nan,
126
+ exact=np.mean(np.isclose(predicted, actual)) * 100))
127
+ return pd.DataFrame(rows)
128
+
129
+ def _as_truth(self, truth):
130
+ if isinstance(truth, pd.DataFrame):
131
+ values = truth[self.value_col]
132
+ elif isinstance(truth, str) or hasattr(truth, "__fspath__"):
133
+ values = pd.read_csv(truth)[self.value_col]
134
+ else:
135
+ values = truth
136
+ values = np.asarray(values, dtype=float)
137
+ if len(values) != len(self.y):
138
+ raise ValueError(f"truth ยาว {len(values)} แถว แต่ข้อมูลยาว {len(self.y)} แถว")
139
+ return values
140
+
141
+ def plot(self, truth=None, methods=None, save=None, show=True, title=None):
142
+ import matplotlib.pyplot as plt
143
+
144
+ self._ensure_fit()
145
+ methods = methods or self.methods
146
+ truth_values = None if truth is None else self._as_truth(truth)
147
+ scores = None
148
+ if truth_values is not None and self.gaps:
149
+ scores = self.evaluate(truth_values, methods).set_index("method")
150
+
151
+ ncol = 2 if len(methods) > 1 else 1
152
+ nrow = int(np.ceil(len(methods) / ncol))
153
+ fig, axes = plt.subplots(nrow, ncol, figsize=(7 * ncol, 4 * nrow),
154
+ sharex=True, sharey=True, squeeze=False)
155
+ window = self._plot_window()
156
+ for ax, method in zip(axes.flat, methods):
157
+ score = None if scores is None else scores.loc[method]
158
+ self._draw_method(ax, method, truth_values, window, score)
159
+ for ax in axes.flat[len(methods):]:
160
+ ax.axis("off")
161
+ for ax in axes[-1]:
162
+ ax.set_xlabel("record #")
163
+ if title:
164
+ fig.suptitle(title, fontsize=12)
165
+ fig.tight_layout()
166
+ if save:
167
+ fig.savefig(save, dpi=120)
168
+ if show:
169
+ plt.show()
170
+ else:
171
+ plt.close(fig)
172
+ return fig
173
+
174
+ def _plot_window(self):
175
+ missing = np.flatnonzero(~self.known)
176
+ if not len(missing):
177
+ return slice(None)
178
+ pad = max(len(missing), PLOT_MIN_PAD)
179
+ return slice(max(0, missing[0] - pad), min(len(self.y), missing[-1] + pad + 1))
180
+
181
+ def _draw_method(self, ax, method, truth, window, score):
182
+ filled, _ = self._fill_values(method)
183
+ idx = np.arange(len(self.y))
184
+ observed = self.known[window]
185
+ if truth is not None:
186
+ ax.plot(idx[window], truth[window], "-", color="0.6", lw=1, label="truth")
187
+ ax.plot(idx[window][observed], self.y[window][observed], ".", color="tab:blue", ms=3, label="observed")
188
+ # วาดทีละรู ไม่อย่างนั้นเส้นจะลากข้ามช่วงที่มีข้อมูลจริงระหว่างรู
189
+ for k, (i0, i1) in enumerate(self.gaps):
190
+ gap = slice(i0, i1 + 1)
191
+ label = method if k == 0 else None
192
+ ax.axvspan(i0, i1, color="orange", alpha=0.07)
193
+ if method.endswith("snap"):
194
+ ax.step(idx[gap], filled[gap], where="mid", color="tab:red", lw=1.8, label=label)
195
+ else:
196
+ ax.plot(idx[gap], filled[gap], "-", color="tab:red", lw=1.8, label=label)
197
+ heading = method
198
+ if score is not None:
199
+ heading += f"\nMAE={score.MAE:.4f} RMSE={score.RMSE:.4f} R2={score.R2:.3f}"
200
+ ax.set_title(heading, fontsize=10)
201
+ ax.legend(fontsize=7)
202
+
203
+
204
+ def _build_time(datetime_col):
205
+ text = datetime_col.astype(str)
206
+ t = pd.to_datetime(text, format="ISO8601", errors="coerce")
207
+ if t.notna().mean() > 0.5:
208
+ if t.isna().any():
209
+ t[t.isna()] = pd.to_datetime(text[t.isna()], format="mixed", dayfirst=True, errors="coerce")
210
+ if (t.max() - t.min()) > REAL_TIME_MIN_SPAN:
211
+ return (t - t.min()).dt.total_seconds().to_numpy(), True
212
+ mmss = pd.to_datetime(text, format="%M:%S.%f", errors="coerce")
213
+ # ไฟล์เก็บแค่นาที:วินาที ค่าที่ลดลงแปลว่าข้ามไปชั่วโมงถัดไป
214
+ hours_passed = (mmss.diff() < pd.Timedelta(0)).cumsum()
215
+ mmss = mmss + pd.to_timedelta(hours_passed, unit="h")
216
+ return (mmss - mmss.min()).dt.total_seconds().to_numpy(), False
217
+
218
+
219
+ def _detect_grid(vals):
220
+ v = vals[np.isfinite(vals)]
221
+ distinct = np.unique(v)
222
+ if len(distinct) < 3:
223
+ return None
224
+ steps = np.diff(distinct)
225
+ step = np.min(steps[steps > 1e-9])
226
+ multiples = v / step
227
+ on_grid = np.abs(multiples - np.round(multiples)) < 1e-6
228
+ if step >= GRID_MIN_STEP and np.mean(on_grid) > GRID_MIN_FIT_SHARE:
229
+ return float(step)
230
+ return None
231
+
232
+
233
+ def _detect_period(x, y, known, center, band_h):
234
+ near = known & (np.abs(x - center) < PERIOD_SEARCH_HALF_WINDOW)
235
+ if near.sum() < PERIOD_MIN_POINTS:
236
+ return None
237
+ xm, ym = x[near], y[near]
238
+ grid_x = np.arange(xm.min(), xm.max(), PERIOD_RESAMPLE_STEP)
239
+ grid_y = np.interp(grid_x, xm, ym)
240
+ grid_y = grid_y - grid_y.mean()
241
+ spectrum = np.abs(np.fft.rfft(grid_y * np.hanning(len(grid_y))))[1:]
242
+ period_h = 1 / np.fft.rfftfreq(len(grid_x), PERIOD_RESAMPLE_STEP)[1:] / HOUR
243
+ in_band = (period_h > band_h[0]) & (period_h < band_h[1])
244
+ if not in_band.any():
245
+ return None
246
+ band_spectrum = spectrum[in_band]
247
+ if band_spectrum.max() < PERIOD_MIN_PEAK_TO_MEDIAN * np.median(spectrum):
248
+ return None
249
+ return period_h[in_band][np.argmax(band_spectrum)] * HOUR
250
+
251
+
252
+ def _gap_runs(known):
253
+ edges = np.diff(np.concatenate(([0], (~known).astype(int), [0])))
254
+ starts = np.flatnonzero(edges == 1)
255
+ ends = np.flatnonzero(edges == -1) - 1
256
+ return list(zip(starts.tolist(), ends.tolist()))
257
+
258
+
259
+ def _linear(x, y, known):
260
+ filled = y.copy()
261
+ filled[~known] = np.interp(x[~known], x[known], y[known])
262
+ return filled
263
+
264
+
265
+ def _harmonic(x, y, known, i0, i1, period, nharm=2, win_days=5, sigma=4):
266
+ xg = x[i0:i1 + 1]
267
+ center = xg.mean()
268
+
269
+ def design(xx):
270
+ cols = [np.ones_like(xx), (xx - center) / DAY]
271
+ for h in range(1, nharm + 1):
272
+ w = 2 * np.pi * xx / (period / h)
273
+ cols += [np.sin(w), np.cos(w)]
274
+ return np.column_stack(cols)
275
+
276
+ # หน้าต่างกว้างกว่า win_days อีกหนึ่งคาบ+1 วัน เพื่อให้มีข้อมูลครบรอบคลื่นทั้งสองฝั่งของรู
277
+ win = known & (np.abs(x - center) < (win_days + period / DAY + 1) * DAY)
278
+ weight = np.exp(-((x[win] - center) / (sigma * DAY)) ** 2)
279
+ coef, *_ = np.linalg.lstsq(design(x[win]) * weight[:, None], y[win] * weight, rcond=None)
280
+ pred = design(xg) @ coef
281
+ iL, iR = i0 - 1, i1 + 1
282
+ # ไซน์ที่ฟิตได้ไม่ผ่านค่าจริงที่ขอบรูพอดี เลยเกลี่ยส่วนต่างที่สองขอบเป็นเส้นตรงให้ปลายต่อกันสนิท
283
+ if iL >= 0 and iR < len(x):
284
+ resL = y[iL] - (design(x[iL:iL + 1]) @ coef)[0]
285
+ resR = y[iR] - (design(x[iR:iR + 1]) @ coef)[0]
286
+ f = (xg - x[iL]) / (x[iR] - x[iL])
287
+ pred = pred + resL * (1 - f) + resR * f
288
+ return pred
289
+
290
+
291
+ def _snap(v, grid):
292
+ return np.round(v / grid) * grid
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"] # 77 ขึ้นไปถึงรับ license แบบ SPDX string
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "findermiss"
7
+ dynamic = ["version"]
8
+ description = "Fill missing values in time series (linear / harmonic / snap)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "NOPPARAT CHEUNARROM" }]
13
+ dependencies = [
14
+ "numpy",
15
+ "pandas>=2.0", # ใช้ to_datetime(format="ISO8601" / "mixed") ซึ่งมีตั้งแต่ 2.0
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ plot = ["matplotlib"]
20
+
21
+ [tool.setuptools]
22
+ py-modules = ["findermiss"]
23
+
24
+ [tool.setuptools.dynamic]
25
+ version = {attr = "findermiss.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+