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.
- findermiss-0.1.0/LICENSE +21 -0
- findermiss-0.1.0/PKG-INFO +129 -0
- findermiss-0.1.0/README.md +115 -0
- findermiss-0.1.0/findermiss.egg-info/PKG-INFO +129 -0
- findermiss-0.1.0/findermiss.egg-info/SOURCES.txt +9 -0
- findermiss-0.1.0/findermiss.egg-info/dependency_links.txt +1 -0
- findermiss-0.1.0/findermiss.egg-info/requires.txt +5 -0
- findermiss-0.1.0/findermiss.egg-info/top_level.txt +1 -0
- findermiss-0.1.0/findermiss.py +292 -0
- findermiss-0.1.0/pyproject.toml +25 -0
- findermiss-0.1.0/setup.cfg +4 -0
findermiss-0.1.0/LICENSE
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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__"}
|