@@ -211,3 +211,78 @@ def test_cycle_multivariate_with_innovations_and_cycle_length(rng):
211211 for i in range (3 ):
212212 expected_Q [2 * i : 2 * i + 2 , 2 * i : 2 * i + 2 ] = np .eye (2 ) * sigmas [i ] ** 2
213213 np .testing .assert_allclose (Q , expected_Q )
214+
215+
216+ def test_add_multivariate_cycle_components_with_different_observed ():
217+ """
218+ Test adding two multivariate CycleComponents with different observed_state_names.
219+ Ensures that combining two multivariate CycleComponents with different observed state names
220+ results in the correct block-diagonal state space matrices and state naming.
221+ """
222+ cycle1 = st .CycleComponent (
223+ name = "cycle1" ,
224+ cycle_length = 12 ,
225+ estimate_cycle_length = False ,
226+ innovations = False ,
227+ observed_state_names = ["a1" , "a2" ],
228+ )
229+ cycle2 = st .CycleComponent (
230+ name = "cycle2" ,
231+ cycle_length = 6 ,
232+ estimate_cycle_length = False ,
233+ innovations = False ,
234+ observed_state_names = ["b1" , "b2" ],
235+ )
236+ mod = (cycle1 + cycle2 ).build (verbose = False )
237+
238+ # check dimensions
239+ assert mod .k_endog == 4
240+ assert mod .k_states == 8
241+ assert mod .k_posdef == 2 * mod .k_endog # 2 innovations per variable
242+
243+ # check state names and coords
244+ expected_state_names = [
245+ "cycle1_Cos[a1]" ,
246+ "cycle1_Sin[a1]" ,
247+ "cycle1_Cos[a2]" ,
248+ "cycle1_Sin[a2]" ,
249+ "cycle2_Cos[b1]" ,
250+ "cycle2_Sin[b1]" ,
251+ "cycle2_Cos[b2]" ,
252+ "cycle2_Sin[b2]" ,
253+ ]
254+ assert mod .state_names == expected_state_names
255+
256+ assert mod .coords ["cycle1_state" ] == ["cycle1_Cos" , "cycle1_Sin" ]
257+ assert mod .coords ["cycle2_state" ] == ["cycle2_Cos" , "cycle2_Sin" ]
258+ assert mod .coords ["cycle1_endog" ] == ["a1" , "a2" ]
259+ assert mod .coords ["cycle2_endog" ] == ["b1" , "b2" ]
260+
261+ # evaluate design, transition, selection matrices
262+ Z , T , R = pytensor .function (
263+ [], [mod .ssm ["design" ], mod .ssm ["transition" ], mod .ssm ["selection" ]], mode = "FAST_COMPILE"
264+ )()
265+
266+ # design: each row selects first state of its block
267+ expected_Z = np .zeros ((4 , 8 ))
268+ expected_Z [0 , 0 ] = 1.0 # "a1" -> cycle1_Cos[a1]
269+ expected_Z [1 , 2 ] = 1.0 # "a2" -> cycle1_Cos[a2]
270+ expected_Z [2 , 4 ] = 1.0 # "b1" -> cycle2_Cos[b1]
271+ expected_Z [3 , 6 ] = 1.0 # "b2" -> cycle2_Cos[b2]
272+ assert_allclose (Z , expected_Z )
273+
274+ # transition: block diagonal, each block is 2x2 frequency transition matrix
275+ block1 = _frequency_transition_block (12 , 1 ).eval ()
276+ block2 = _frequency_transition_block (6 , 1 ).eval ()
277+ expected_T = np .zeros ((8 , 8 ))
278+ for i in range (2 ):
279+ expected_T [2 * i : 2 * i + 2 , 2 * i : 2 * i + 2 ] = block1
280+ for i in range (2 ):
281+ expected_T [4 + 2 * i : 4 + 2 * i + 2 , 4 + 2 * i : 4 + 2 * i + 2 ] = block2
282+ assert_allclose (T , expected_T )
283+
284+ # selection: block diagonal, each block is 2x2 identity
285+ expected_R = np .zeros ((8 , 8 ))
286+ for i in range (4 ):
287+ expected_R [2 * i : 2 * i + 2 , 2 * i : 2 * i + 2 ] = np .eye (2 )
288+ assert_allclose (R , expected_R )
0 commit comments