1+ import type { TrackerConfig , TrackingState , ThresholdCrossing , TrackingStats } from './types' ;
2+
3+ export class ValueTracker {
4+ private readonly config : TrackerConfig ;
5+ private history : Array < { value : number ; timestamp : number ; state : 'within' | 'above' | 'below' } > ;
6+ private currentIndex : number ;
7+ private currentState : 'within' | 'above' | 'below' | null ;
8+ private crossings : ThresholdCrossing [ ] ;
9+ private stats : { timeInRange : number ; totalCrossings : number ; lastCrossing : Date | null ; currentStatus : 'within' | 'above' | 'below' } ;
10+ private lastTimestamp : number | null ;
11+
12+ constructor ( config : TrackerConfig ) {
13+ this . config = config ;
14+ this . history = [ ] ;
15+ this . currentIndex = 0 ;
16+ this . currentState = null ;
17+ this . crossings = [ ] ;
18+ this . stats = { timeInRange : 0 , totalCrossings : 0 , lastCrossing : null , currentStatus : 'within' } ;
19+ this . lastTimestamp = null ;
20+ }
21+
22+ processValue ( value : number ) : void {
23+ const precision = this . config . valuePrecisionDigits ;
24+ const roundedValue = Number ( value . toFixed ( precision ) ) ;
25+ const timestamp = Date . now ( ) ;
26+ const newState = this . calculateNewState ( roundedValue ) ;
27+
28+ if ( this . currentState !== null && newState !== this . currentState ) {
29+ const crossing = this . determineCrossing ( this . currentState , newState ) ;
30+ this . crossings . push ( {
31+ timestamp : new Date ( timestamp ) ,
32+ value : roundedValue ,
33+ direction : crossing . direction ,
34+ } ) ;
35+ this . stats . totalCrossings ++ ;
36+ this . stats . lastCrossing = new Date ( timestamp ) ;
37+ }
38+
39+ if ( this . lastTimestamp !== null && this . currentState !== null ) {
40+ const delta = timestamp - this . lastTimestamp ;
41+ if ( this . currentState === 'within' ) {
42+ this . stats . timeInRange += delta ;
43+ }
44+ }
45+
46+ this . currentState = newState ;
47+ this . stats . currentStatus = newState ;
48+ this . lastTimestamp = timestamp ;
49+
50+ const entry = { value : roundedValue , timestamp, state : newState } ;
51+ const maxHistory = Math . floor ( this . config . samplingIntervalMs > 0 ? 1000 : 100 ) ;
52+ if ( this . history . length < maxHistory ) {
53+ this . history . push ( entry ) ;
54+ } else {
55+ this . history [ this . currentIndex ] = entry ;
56+ this . currentIndex = ( this . currentIndex + 1 ) % maxHistory ;
57+ }
58+ }
59+
60+ getStats ( ) : TrackingStats {
61+ const totalTime = this . lastTimestamp !== null && this . history . length > 0
62+ ? this . lastTimestamp - this . history [ 0 ] . timestamp
63+ : 0 ;
64+ const timeInRange = totalTime > 0 ? this . stats . timeInRange : 0 ;
65+ return {
66+ timeInRange,
67+ lastCrossing : this . stats . lastCrossing ?? undefined ,
68+ totalCrossings : this . stats . totalCrossings ,
69+ currentStatus : this . stats . currentStatus
70+ } ;
71+ }
72+
73+ getHistory ( ) : Array < { value : number ; timestamp : number ; state : 'within' | 'above' | 'below' } > {
74+ return [ ...this . history ] ;
75+ }
76+
77+ getCrossings ( ) : ThresholdCrossing [ ] {
78+ return [ ...this . crossings ] ;
79+ }
80+
81+ reset ( ) : void {
82+ this . history = [ ] ;
83+ this . currentIndex = 0 ;
84+ this . currentState = null ;
85+ this . crossings = [ ] ;
86+ this . stats = { timeInRange : 0 , totalCrossings : 0 , lastCrossing : null , currentStatus : 'within' } ;
87+ this . lastTimestamp = null ;
88+ }
89+
90+ private calculateNewState ( value : number ) : 'within' | 'above' | 'below' {
91+ if ( this . currentState === null ) {
92+ return this . determineInitialState ( value ) ;
93+ }
94+
95+ const { lower, upper, lowerHysteresis = 0 , upperHysteresis = 0 } = this . config . thresholds ;
96+ let adjustedLower = lower ;
97+ let adjustedUpper = upper ;
98+
99+ switch ( this . currentState ) {
100+ case 'above' : adjustedUpper -= upperHysteresis ; break ;
101+ case 'below' : adjustedLower += lowerHysteresis ; break ;
102+ }
103+
104+ if ( value > adjustedUpper ) return 'above' ;
105+ if ( value < adjustedLower ) return 'below' ;
106+ return 'within' ;
107+ }
108+
109+ private determineInitialState ( value : number ) : 'within' | 'above' | 'below' {
110+ const { lower, upper } = this . config . thresholds ;
111+ if ( value > upper ) return 'above' ;
112+ if ( value < lower ) return 'below' ;
113+ return 'within' ;
114+ }
115+
116+ private determineCrossing (
117+ previousState : 'within' | 'above' | 'below' ,
118+ newState : 'within' | 'above' | 'below'
119+ ) : { direction : 'above' | 'below' } {
120+ if ( previousState === 'within' && newState === 'above' ) return { direction : 'above' } ;
121+ if ( previousState === 'above' && newState === 'within' ) return { direction : 'below' } ;
122+ if ( previousState === 'within' && newState === 'below' ) return { direction : 'below' } ;
123+ if ( previousState === 'below' && newState === 'within' ) return { direction : 'above' } ;
124+ if ( previousState === 'above' && newState === 'below' ) return { direction : 'below' } ;
125+ if ( previousState === 'below' && newState === 'above' ) return { direction : 'above' } ;
126+ throw new Error ( `Unexpected state transition: ${ previousState } →${ newState } ` ) ;
127+ }
128+ }
0 commit comments