Coverage for src/foapy/core/_intervals_chain.py: 100%

37 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 19:57 +0000

1import numpy as np 

2from numpy import ndarray 

3 

4from foapy.core._binding import binding as binding_cls 

5from foapy.core._chain_mode import chain_mode as chain_mode_cls 

6from foapy.exceptions import Not1DArrayException 

7 

8 

9def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: 

10 """ 

11 Compute the raw intervals chain from a sequence. 

12 

13 The intervals chain is the n-tuple of distances between consecutive 

14 occurrences of the same element, with the first occurrence's distance 

15 measured from the sequence boundary (or cyclically). The chain values 

16 alone are sufficient to determine both binding direction and chain mode 

17 via structural mathematical properties. 

18 

19 Parameters 

20 ---------- 

21 X : array_like 

22 Input sequence (strings, integers, or any comparable elements). 

23 Must be 1-dimensional. No pre-ordering via ``order()`` is required. 

24 binding : int 

25 ``binding.start`` (1) — intervals extracted left-to-right. 

26 ``binding.end`` (2) — intervals extracted right-to-left. 

27 chain_mode : int 

28 ``chain_mode.boundary`` (1) — sequence treated as finite; boundary 

29 intervals are distances from sequence edges to first/last occurrence. 

30 ``chain_mode.cycle`` (2) — sequence treated as circular; leading and 

31 trailing boundary distances are combined into a single cyclic interval. 

32 

33 Returns 

34 ------- 

35 ndarray 

36 Plain 1-D ndarray of dtype ``intp`` containing the raw interval chain 

37 in original sequence order. All values are positive integers ≥ 1 and 

38 ≤ ``len(X)``. 

39 

40 Raises 

41 ------ 

42 Not1DArrayException 

43 When ``X`` is not a 1-dimensional array. 

44 ValueError 

45 When ``binding`` is not ``binding.start`` or ``binding.end``. 

46 When ``chain_mode`` is not ``chain_mode.boundary`` or ``chain_mode.cycle``. 

47 

48 Examples 

49 -------- 

50 

51 ``` py linenums="1" 

52 import foapy 

53 

54 X = ['b', 'a', 'b', 'c', 'b'] 

55 chain = foapy.intervals_chain(X, foapy.binding.start, foapy.chain_mode.boundary) 

56 print(chain) # [1 2 2 4 2] 

57 ``` 

58 

59 ``` py linenums="1" 

60 import foapy 

61 

62 X = ['b', 'a', 'b', 'c', 'b'] 

63 chain = foapy.intervals_chain(X, foapy.binding.start, foapy.chain_mode.cycle) 

64 print(chain) # [1 5 2 5 2] 

65 ``` 

66 

67 ``` py linenums="1" 

68 import foapy 

69 

70 source = [] 

71 chain = foapy.intervals_chain(source, foapy.binding.start, foapy.chain_mode.boundary) # noqa: E501 

72 print(chain) # [] 

73 ``` 

74 """ 

75 if binding not in {binding_cls.start, binding_cls.end}: 

76 raise ValueError( 

77 {"message": "Invalid binding value. Use binding.start or binding.end."} 

78 ) 

79 

80 if chain_mode not in {chain_mode_cls.boundary, chain_mode_cls.cycle}: 

81 raise ValueError( 

82 { 

83 "message": ( 

84 "Invalid chain_mode value. " 

85 "Use chain_mode.boundary or chain_mode.cycle." 

86 ) 

87 } 

88 ) 

89 

90 ar = np.asanyarray(X) 

91 

92 if ar.ndim != 1: 

93 raise Not1DArrayException( 

94 {"message": (f"Incorrect array form. Expected d1 array, exists {ar.ndim}")} 

95 ) 

96 

97 if ar.shape[0] == 0: 

98 return np.array([], dtype=np.intp) 

99 

100 if binding == binding_cls.end: 

101 ar = ar[::-1] 

102 

103 perm = ar.argsort(kind="mergesort") 

104 

105 n = ar.shape[0] 

106 mask = np.empty(n + 1, dtype=bool) 

107 mask[:1] = True 

108 mask[1:-1] = ar[perm[1:]] != ar[perm[:-1]] 

109 mask[-1:] = True 

110 

111 first_mask = mask[:-1] 

112 last_mask = mask[1:] 

113 

114 chain = np.empty(ar.shape, dtype=np.intp) 

115 chain[1:] = perm[1:] - perm[:-1] 

116 

117 if chain_mode == chain_mode_cls.cycle: 

118 delta = n - perm[last_mask] 

119 else: 

120 delta = 1 

121 

122 chain[first_mask] = perm[first_mask] + delta 

123 

124 inverse_perm = np.empty(ar.shape, dtype=np.intp) 

125 inverse_perm[perm] = np.arange(n) 

126 

127 result = chain[inverse_perm] 

128 

129 if binding == binding_cls.end: 

130 result = result[::-1] 

131 

132 return result