mirror of
https://github.com/taigrr/most-specific-period.git
synced 2026-04-10 07:21:24 -07:00
Compare commits
3 Commits
v1.2.1
...
cd/godoc-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d0a2d0cf4 | |||
| 34a9fe93f6 | |||
| 523d062b04 |
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (C) 2021-2022 by Tai Groot <tai@taigrr.com>
|
||||
Copyright (C) 2021-2026 by Tai Groot <tai@taigrr.com>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
109
README.md
109
README.md
@@ -1,21 +1,104 @@
|
||||
# Most Specific Period
|
||||
|
||||
An MSP, or Most Specific Period is defined as follows:
|
||||
- Given a period that hasn’t started or has already finished, an error is returned.
|
||||
- Given a period that is currently valid, the period which is valid is chosen.
|
||||
- Given two valid periods, one which is a month long and one which is a week long and completely overlap, the week-long period is chosen.
|
||||
- Given two valid periods, one which is a week long and one which is a month long, but only overlap by a day (in either direction) the week-long period is selected.
|
||||
- Given two valid periods, each exactly 30 days long, but offset by a week, the second (newer) period is given precedence.
|
||||
- Given two valid periods with exact same start and end time, the period with the lowest-ranking lexicographic identifier is returned (i.e. period B has precedence over period A, as ‘B’ comes after ‘A’ lexicographically.) This is because the default behavior is to choose the newer period, and named periods are often in lexicographical order (increasing numbers, letters, etc.)
|
||||
[](https://pkg.go.dev/github.com/taigrr/most-specific-period)
|
||||
[](https://goreportcard.com/report/github.com/taigrr/most-specific-period)
|
||||
|
||||
This library operates off Period interfaces, which contain the following:
|
||||
A Go library for selecting the narrowest time period containing a given
|
||||
timestamp from a set of potentially overlapping periods.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/taigrr/most-specific-period
|
||||
```
|
||||
type Period interface {
|
||||
GetStartTime() time.Time // Inclusive start time
|
||||
GetEndTime() time.Time // Exclusive end time ("expiration time")
|
||||
GetIdentifier() string
|
||||
|
||||
## What is a Most Specific Period?
|
||||
|
||||
Given overlapping time periods, the MSP algorithm picks the most precise one:
|
||||
|
||||
- Given a single valid period containing the timestamp, that period is chosen.
|
||||
- Given two overlapping periods of different lengths, the shorter one wins.
|
||||
- Given two periods of equal length, the one that started more recently wins.
|
||||
- Given two periods with the same duration and start time, the lexicographically
|
||||
last identifier wins (e.g. "B" over "A").
|
||||
- Periods that haven't started yet or have already ended are ignored.
|
||||
|
||||
## Usage
|
||||
|
||||
Implement the `Period` interface or use the built-in `TimeWindow`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/taigrr/most-specific-period/msp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
now := time.Now()
|
||||
|
||||
periods := []msp.Period{
|
||||
msp.TimeWindow{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
EndTime: now.Add(24 * time.Hour),
|
||||
Identifier: "this-week",
|
||||
},
|
||||
msp.TimeWindow{
|
||||
StartTime: now.Add(-1 * time.Hour),
|
||||
EndTime: now.Add(1 * time.Hour),
|
||||
Identifier: "this-morning",
|
||||
},
|
||||
}
|
||||
|
||||
id, err := msp.MostSpecificPeriod(now, periods...)
|
||||
if err != nil {
|
||||
fmt.Println("No matching period")
|
||||
return
|
||||
}
|
||||
fmt.Printf("MSP: %s\n", id) // "this-morning"
|
||||
}
|
||||
```
|
||||
|
||||
An example program is available to observe the operation and usage of this library.
|
||||
### Period Interface
|
||||
|
||||
```go
|
||||
type Period interface {
|
||||
GetStartTime() time.Time // Inclusive start time
|
||||
GetEndTime() time.Time // Exclusive end time
|
||||
GetIdentifier() string
|
||||
}
|
||||
```
|
||||
|
||||
### Additional Functions
|
||||
|
||||
- `GenerateTimeline(periods...)` — Flatten overlapping periods into a
|
||||
non-overlapping timeline.
|
||||
- `GetChangeOvers(periods...)` — Get timestamps where the MSP changes.
|
||||
- `GetNextChangeOver(t, periods...)` — Get the next changeover after time `t`.
|
||||
- `FlattenPeriods(periods...)` — Get ordered identifiers at each changeover.
|
||||
- `ValidTimePeriods(ts, periods...)` — Filter periods valid at timestamp `ts`.
|
||||
- `GetDuration(start, end)` — Calculate duration between two times.
|
||||
|
||||
## CLI
|
||||
|
||||
A demo CLI is included. It reads periods from stdin (one per three lines:
|
||||
identifier, start time, end time in RFC 3339) and displays the timeline
|
||||
and MSP.
|
||||
|
||||
```bash
|
||||
go run . -d 2024-06-15T12:00:00Z <<EOF
|
||||
summer
|
||||
2024-06-01T00:00:00Z
|
||||
2024-09-01T00:00:00Z
|
||||
june
|
||||
2024-06-01T00:00:00Z
|
||||
2024-07-01T00:00:00Z
|
||||
EOF
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
0BSD — See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetChangeOvers returns the sorted list of timestamps where the most
|
||||
// specific period changes from one identifier to another.
|
||||
func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
|
||||
timeStamps := []time.Time{}
|
||||
for _, x := range periods {
|
||||
@@ -37,6 +39,8 @@ func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
|
||||
return
|
||||
}
|
||||
|
||||
// GetNextChangeOver returns the first changeover timestamp strictly after t.
|
||||
// If no such changeover exists, ErrNoNextChangeover is returned.
|
||||
func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) {
|
||||
changeOvers := GetChangeOvers(periods...)
|
||||
for _, ts := range changeOvers {
|
||||
@@ -47,6 +51,8 @@ func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error)
|
||||
return time.Time{}, ErrNoNextChangeover
|
||||
}
|
||||
|
||||
// FlattenPeriods returns an ordered list of period identifiers representing
|
||||
// the most specific period at each changeover point.
|
||||
func FlattenPeriods(periods ...Period) (ids []string) {
|
||||
changeovers := GetChangeOvers(periods...)
|
||||
for _, c := range changeovers {
|
||||
|
||||
12
msp/msp.go
12
msp/msp.go
@@ -5,6 +5,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostSpecificPeriod returns the identifier of the shortest-duration period
|
||||
// that contains timestamp ts. When multiple periods share the shortest
|
||||
// duration, the one with the latest start time wins; if start times also
|
||||
// match, the lexicographically last identifier is returned.
|
||||
func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error) {
|
||||
// Filter to get only valid periods here
|
||||
periods = ValidTimePeriods(ts, periods...)
|
||||
@@ -12,7 +16,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
|
||||
return "", ErrNoValidPeriods
|
||||
}
|
||||
// find the shortest duration
|
||||
d, err := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime())
|
||||
d, _ := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime())
|
||||
for _, x := range periods {
|
||||
p, err := GetDuration(x.GetStartTime(), x.GetEndTime())
|
||||
if err == nil && p < d {
|
||||
@@ -34,7 +38,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
|
||||
newest = x.GetStartTime()
|
||||
}
|
||||
}
|
||||
// Determine whichever of these periods have the same start time in addtion to duration
|
||||
// Determine whichever of these periods have the same start time in addition to duration
|
||||
var matchingDurationsAndStartTimes []Period
|
||||
for _, x := range matchingDurations {
|
||||
if x.GetStartTime() == newest {
|
||||
@@ -50,6 +54,8 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
|
||||
return identifiers[len(identifiers)-1], nil
|
||||
}
|
||||
|
||||
// GetDuration returns the duration between start and end. If start is after
|
||||
// end, ErrEndAfterStart is returned alongside the (negative) duration.
|
||||
func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error) {
|
||||
if start.After(end) {
|
||||
err = ErrEndAfterStart
|
||||
@@ -58,6 +64,8 @@ func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error)
|
||||
return dur, err
|
||||
}
|
||||
|
||||
// ValidTimePeriods filters periods to those whose start time is at or before
|
||||
// ts and whose end time is strictly after ts.
|
||||
func ValidTimePeriods(ts time.Time, periods ...Period) []Period {
|
||||
var valid []Period
|
||||
for _, p := range periods {
|
||||
|
||||
152
msp/msp_test.go
152
msp/msp_test.go
@@ -5,7 +5,157 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// (periods ...Period) (id string, err error) {
|
||||
func TestGetDuration(t *testing.T) {
|
||||
now := time.Now()
|
||||
testCases := []struct {
|
||||
testID string
|
||||
start time.Time
|
||||
end time.Time
|
||||
dur time.Duration
|
||||
err error
|
||||
}{
|
||||
{
|
||||
testID: "Normal duration",
|
||||
start: now,
|
||||
end: now.Add(5 * time.Minute),
|
||||
dur: 5 * time.Minute,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
testID: "Zero duration",
|
||||
start: now,
|
||||
end: now,
|
||||
dur: 0,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
testID: "Start after end",
|
||||
start: now.Add(5 * time.Minute),
|
||||
end: now,
|
||||
dur: -5 * time.Minute,
|
||||
err: ErrEndAfterStart,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.testID, func(t *testing.T) {
|
||||
dur, err := GetDuration(tc.start, tc.end)
|
||||
if dur != tc.dur {
|
||||
t.Errorf("Duration %v does not match expected %v", dur, tc.dur)
|
||||
}
|
||||
if err != tc.err {
|
||||
t.Errorf("Error %v does not match expected %v", err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidTimePeriods(t *testing.T) {
|
||||
now := time.Now()
|
||||
testCases := []struct {
|
||||
testID string
|
||||
ts time.Time
|
||||
periods []Period
|
||||
count int
|
||||
}{
|
||||
{
|
||||
testID: "No periods",
|
||||
ts: now,
|
||||
periods: []Period{},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
testID: "One valid period",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-time.Minute),
|
||||
EndTime: now.Add(time.Minute),
|
||||
Identifier: "A",
|
||||
},
|
||||
},
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
testID: "Period in the past",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-2 * time.Minute),
|
||||
EndTime: now.Add(-time.Minute),
|
||||
Identifier: "A",
|
||||
},
|
||||
},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
testID: "Period in the future",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now.Add(time.Minute),
|
||||
EndTime: now.Add(2 * time.Minute),
|
||||
Identifier: "A",
|
||||
},
|
||||
},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
testID: "Timestamp equals start time (inclusive)",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now,
|
||||
EndTime: now.Add(time.Minute),
|
||||
Identifier: "A",
|
||||
},
|
||||
},
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
testID: "Timestamp equals end time (exclusive)",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-time.Minute),
|
||||
EndTime: now,
|
||||
Identifier: "A",
|
||||
},
|
||||
},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
testID: "Mixed valid and invalid",
|
||||
ts: now,
|
||||
periods: []Period{
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-time.Minute),
|
||||
EndTime: now.Add(time.Minute),
|
||||
Identifier: "A",
|
||||
},
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-3 * time.Minute),
|
||||
EndTime: now.Add(-2 * time.Minute),
|
||||
Identifier: "B",
|
||||
},
|
||||
TimeWindow{
|
||||
StartTime: now.Add(-5 * time.Minute),
|
||||
EndTime: now.Add(5 * time.Minute),
|
||||
Identifier: "C",
|
||||
},
|
||||
},
|
||||
count: 2,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.testID, func(t *testing.T) {
|
||||
valid := ValidTimePeriods(tc.ts, tc.periods...)
|
||||
if len(valid) != tc.count {
|
||||
t.Errorf("Got %d valid periods, expected %d", len(valid), tc.count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMostSpecificPeriod(t *testing.T) {
|
||||
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
|
||||
now := time.Now()
|
||||
|
||||
@@ -5,24 +5,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeWindow is a concrete implementation of the Period interface.
|
||||
type TimeWindow struct {
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Identifier string
|
||||
}
|
||||
|
||||
// GetIdentifier returns the period's identifier string.
|
||||
func (p TimeWindow) GetIdentifier() string {
|
||||
return p.Identifier
|
||||
}
|
||||
|
||||
// GetEndTime returns the period's exclusive end time.
|
||||
func (p TimeWindow) GetEndTime() time.Time {
|
||||
return p.EndTime
|
||||
}
|
||||
|
||||
// GetStartTime returns the period's inclusive start time.
|
||||
func (p TimeWindow) GetStartTime() time.Time {
|
||||
return p.StartTime
|
||||
}
|
||||
|
||||
// String returns a tab-separated representation of the time window.
|
||||
func (t TimeWindow) String() string {
|
||||
return fmt.Sprintf("%s\t%s\t%s",
|
||||
t.GetIdentifier(),
|
||||
@@ -30,7 +35,8 @@ func (t TimeWindow) String() string {
|
||||
t.GetEndTime())
|
||||
}
|
||||
|
||||
// Outputs a formatted timeline of periods
|
||||
// GenerateTimeline produces a flattened timeline of non-overlapping periods
|
||||
// by splitting overlapping input periods at changeover points.
|
||||
func GenerateTimeline(periods ...Period) (out []Period) {
|
||||
if len(periods) == 0 {
|
||||
return out
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package msp implements the Most Specific Period algorithm, which selects
|
||||
// the narrowest (shortest-duration) time period containing a given timestamp
|
||||
// from a set of potentially overlapping periods.
|
||||
package msp
|
||||
|
||||
import "time"
|
||||
@@ -5,6 +8,7 @@ import "time"
|
||||
// Compile-time interface check.
|
||||
var _ Period = TimeWindow{}
|
||||
|
||||
// Period represents a named time window with inclusive start and exclusive end.
|
||||
type Period interface {
|
||||
GetStartTime() time.Time
|
||||
GetEndTime() time.Time
|
||||
|
||||
Reference in New Issue
Block a user