Skip to content

API Reference

DiagramBuilder

DiagramBuilder

A helper class for incrementally building signal processing diagrams using Matplotlib.

This class provides high-level methods to add standard diagram components like blocks, arrows, combiners, and input/output labels, keeping track of layout and threading.

Parameters:

Name Type Description Default
block_length float

Default horizontal size of blocks.

1.0
block_height float

Default vertical size of blocks.

1.0
fontsize int

Default font size for all text.

20

Returns:

Type Description
DiagramBuilder

created object.

Examples:

>>> from signalblocks import DiagramBuilder
>>> db1 = DiagramBuilder()
>>> db2 = DiagramBuilder(block_length=2, fontsize=16)
Source code in signalblocks\DiagramBuilder.py
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
class DiagramBuilder:
    """
    A helper class for incrementally building signal processing diagrams using Matplotlib.

    This class provides high-level methods to add standard diagram components like blocks, arrows,
    combiners, and input/output labels, keeping track of layout and threading.

    Args:
        block_length (float, optional): Default horizontal size of blocks.
        block_height (float, optional): Default vertical size of blocks.
        fontsize (int, optional): Default font size for all text.

    Returns:
        (DiagramBuilder): created object.

    Examples:
        >>> from signalblocks import DiagramBuilder
        >>> db1 = DiagramBuilder()
        >>> db2 = DiagramBuilder(block_length=2, fontsize=16)
    """
    def __init__(self, block_length=1.0, block_height=1.0, fontsize=20):
        """
        (Private) Creator of the DiagramBuilder class.
        """
        self.fig, self.ax = plt.subplots()
        self.ax.axis('off')  # Hide axes
        self.fontsize = fontsize
        self.block_length = block_length
        self.block_height = block_height
        self.thread_positions = {}
        self.thread_positions['main'] = [0, 0]
        # Dictionary to store element positions: input_pos, output_pos, feedback_pos
        self.element_positions = {}
        # Counter for current element
        self.current_element = -1

    def print_threads(self):
        """
        Prints name of each thread in diagram and actual position.

        Examples:
            >>> from signalblocks import DiagramBuilder
            >>> db = DiagramBuilder(block_length=1, fontsize=16)
            >>> # Upper thread
            >>> db.add("x_1(t)", kind="input", thread='upper', position=(0, 1))
            >>> db.add("mult", kind="combiner", thread='upper', input_text="e^{-j\\omega_0 t}", input_side='top', operation='mult')
            >>> db.add("", kind="line", thread='upper')
            >>> # Lower thread
            >>> db.add("x_2(t)", kind="input", thread='lower', position=(0, -1))
            >>> db.add("mult", kind="combiner", input_text="e^{j\\omega_0 t}", input_side='bottom', operation='mult', thread='lower')
            >>> db.add("", kind="line", thread='lower')
            >>> input_threads = ['upper', 'lower']
            >>> # Adder
            >>> db.add("", kind="mult_combiner", inputs=input_threads, position="auto", operation='sum')
            >>> # Rest of the diagram (main thread)
            >>> db.add("x(t)", kind="output")
            >>> db.show()
            >>> db.print_threads()
        """
        for thread in self.thread_positions:
            print(thread, ": ", self.thread_positions[thread])


    # --- Helper functions ---

    def __get_bbox__(self):
        return self.ax.dataLim

    def __get_rotated_pos__(self, init_pos, outvector, angle):
        """
        Inner method.
        Compute rotated point init_pos + outvector.

        Args:
            init_pos (Numpy.NDArray or list): Initial position of the block (relative origin of coordinates).
            outvector (Numpy.NDArray or list): Output vector before rotation (relative position with respect to init_pos).
            angle (float): Rotation angle in degrees.

        Returns:
            (Numpy.NDArray): Rotated position of vector init_pos + outvector.
        """

        # Output point respect to input point (before rotation)
        out_vector = np.array(outvector)
        # Rotation matrix (without translation)
        rotation_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]
        # Apply rotation to the output vector
        dx, dy = rotation_matrix @ out_vector
        # Add the rotated output vector to the initial position
        return np.array([init_pos[0] + dx, init_pos[1] + dy])

    def __add_element_position__(self, input_pos: Tuple[float, float], 
                                 output_pos: Tuple[float, float], 
                                 feedback_pos: Tuple[float, float]):
        """
        Inner method.
        Adds a new element with the given input, output and feedback positions.

        Args:
            input_pos (Numpy.NDArray or list): Input position of the block.
            output_pos (Numpy.NDArray or list): Output position of the block.
            feedback_pos (Numpy.NDArray or list): Feedback port position of the block.
        """
        self.current_element += 1

        self.element_positions[self.current_element] = ElementPosition(
            input_pos=input_pos,
            output_pos=output_pos,
            feedback_pos=feedback_pos
        )

    # --- Drawing functions ---

    def __draw_rotated_text__(self, anchor_point, text, angle, rotate_text = True,
                      ha='center', va='center', fontsize=16, offset=(0, 0)):
        """
        Inner method.
        Draws text rotated around the anchor point with optional offset. 
        Text position: rotation(anchor_point + offset)

        Args:
            anchor_point (Numpy.NDArray or list): Coordinates of the anchor point.
            text (string): String to display. LaTeX math accepted (without $...$).
            angle (float): Rotation angle in degrees.
            rotate_text (bool, optional): Indicates if text must be rotated or not.
            ha (string, optional): Horizontal alignment: {'center', 'left', 'right'}.
            va (string, optional): Vertical alignment: {'center', 'bottom', 'top'}.
            fontsize (int, optional): Font size.
            offset (Numpy.NDArray or list): Coordinates of texr position respect to anchor point, before rotation.
        """
        # Apply rotation to the offset vector
        dx, dy = offset
        offset_vec = np.array([dx, dy])
        rot_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]
        dx_rot, dy_rot = rot_matrix @ offset_vec

        # Compute final position
        tx = anchor_point[0] + dx_rot
        ty = anchor_point[1] + dy_rot

        if rotate_text is False:
            text_angle = 0
        else:
            text_angle = angle

        # Draw text with angle, rotating around anchor point
        self.ax.text(tx, ty, f"${text}$", ha=ha, va=va, fontsize=fontsize,
                rotation=text_angle, rotation_mode='anchor', transform=self.ax.transData)


    def __draw_block__(self, initial_position, text=None, text_below=None, 
                       text_above=None, text_offset=0.1, input_text=None, 
                       input_side=None, length=1.5, height=1, fontsize=14, 
                       linestyle='-', orientation='horizontal'):
        """
        Inner method.
        Draws a rectangular block with centered text, optional texts below and/or above and optional input arrow with text.

        Args:
            initial_position (Numpy.NDarray or list): Coordinates of the center position of the input edge of the block.
            text (string, optional): Label to display in the block.
            text_below (string, optional): Label to display below the block.
            text_above (string, optional): Label to display above the block.
            text_offset (float, optional): Vertical offset for the text position.
            input_text (string, optional): Label for the optional input arrow (below or above the block).
            input_side (string, optional): Side to place the input arrow: {'bottom', 'top', None}
            length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
            height (float, optional): Vertical height of the block. If not entered, default `block_height` is used.
            fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
            linestyle (string, optional): linestyle of the block edge: {'-, '--, ':', '-.'}.
            orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

        Returns:
            (Numpy.NDArray): Coordinates of the center position of the output edge of the block.
        """
        # Parameters validation
        if input_side not in (None, 'top', 'bottom'):
            raise ValueError(f"Invalid input_side: {input_side}. Use 'top' or 'bottom'.")
        if orientation not in (None, 'horizontal', 'vertical', 'up', 'down', 'left', 'right'):
            if isinstance(orientation, (int, float)):
                pass
            else:
                raise ValueError(f"Invalid orientation: {orientation}. Use 'horizontal', 'vertical', 'up', 'down', 'left', or 'right'.")
        if linestyle not in (None, '-', '--', ':', '-.', 'solid', 'dashed', 'dotted', 'dashdot'):
            raise ValueError(f"Invalid linestyle: {linestyle}. Use '-', '--', ':', '-.', 'solid', 'dashed', 'dotted', or 'dashdot'.")
        if not isinstance(length, (int, float)) or length <= 0:
            raise ValueError(f"Invalid length: {length}. Length must be a positive number.")
        if not isinstance(height, (int, float)) or height <= 0:
            raise ValueError(f"Invalid height: {height}. Height must be a positive number.")
        if not isinstance(text_offset, (int, float)):
            raise ValueError(f"Invalid text_offset: {text_offset}. Text offset must be a number.")
        if not isinstance(fontsize, (int, float)):
            raise ValueError(f"Invalid fontsize: {fontsize}. Font size must be a number.")


        # Determine rotation angle based on orientation
        if orientation in ['horizontal', 'right']:
            angle = 0
        elif orientation == 'left':
            angle = 180
        elif orientation in ['vertical', 'down']:
            angle = -90
        elif orientation == 'up':
            angle = 90
        elif isinstance(orientation, (int, float)):
            angle = orientation
        else:
            angle = 0

        x_in, y_in = initial_position

        # Bottom-left corner of the block (before rotation)
        x0 = x_in
        y0 = y_in - height / 2

        # Center of the block (before rotation)
        cx = x_in + length / 2
        cy = y_in

        # Apply the rotation around the connection point (x_ini, y_ini)
        trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   

        self.ax.add_patch(Rectangle((x0, y0), length, height, 
                                    edgecolor='black', facecolor='none', 
                                    linestyle=linestyle, transform=trans))
        # Don't rotate text if orientation is vertical, down or up
        rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

        # Draw text inside the block
        if text is not None:
            offset_vector = np.array([length / 2, 0])
            self.__draw_rotated_text__(initial_position, text, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha='center', va='center', 
                                       fontsize=fontsize, offset=offset_vector)

        # Draw text above the block
        if text_above is not None:
            if orientation in ['vertical', 'down']:
                ha = 'left'
                va = 'center'
            elif orientation in ['up']:
                ha = 'right'
                va = 'center'
            else:
                ha = 'center'
                va = 'bottom'
            offset_vector = np.array([length / 2, height / 2 + text_offset])
            self.__draw_rotated_text__(initial_position, text_above, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha=ha, va=va, 
                                       fontsize=fontsize, offset=offset_vector)

        # Draw text below the block
        if text_below is not None:
            if orientation in ['vertical', 'down']:
                ha = 'right'
                va = 'center'
            elif orientation in ['up']:
                ha = 'left'
                va = 'center'
            else:
                ha = 'center'
                va = 'top'
            offset_vector = np.array([length / 2, - height / 2 - text_offset])
            self.__draw_rotated_text__(initial_position, text_below, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha=ha, va=va, 
                                       fontsize=fontsize, offset=offset_vector)

        if input_side is not None:
            if input_side == 'bottom':
                arrow_height = 0.75 * height
                y_init = y0 - arrow_height
                offset_vector = np.array([length / 2, - height /2 - arrow_height - text_offset])
                va = 'top'
                ha = 'center'
                if orientation in ['vertical', 'down']:
                    ha = 'right'
                    va = 'center'
                elif orientation in ['up']:
                    ha = 'left'
                    va = 'center'
                elif orientation in ['left']:
                    ha = 'center'
                    va = 'bottom'
            elif input_side == 'top':
                arrow_height = - 0.75 * height
                y_init = y0 + height - arrow_height
                offset_vector = np.array([length / 2, height /2 - arrow_height + text_offset])
                va = 'bottom'
                ha = 'center'
                if orientation in ['vertical', 'down']:
                    ha = 'left'
                    va = 'center'
                elif orientation in ['up']:
                    ha = 'right'
                    va = 'center'
                elif orientation in ['left']:
                    ha = 'center'
                    va = 'top'
            else:
                raise ValueError(f"Unknown input side: {input_side}. Use 'bottom' or 'top'.")   

            self.ax.add_patch(FancyArrow(cx, y_init, 0, arrow_height, width=0.01,
                                    length_includes_head=True, head_width=0.15, 
                                    color='black', transform=trans))
            if input_text is not None:

                self.__draw_rotated_text__(initial_position, input_text, 
                                           angle=angle, rotate_text=rotate_text,
                                           ha=ha, va=va, 
                                           fontsize=fontsize, offset=offset_vector)

        # Compute rotated output point
        output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
        # Compute feedback point
        feedback_pos = self.__get_rotated_pos__(initial_position, [length/2, -height/2], angle)
        # Add element position to the dictionary
        self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                      feedback_pos=feedback_pos)
        return output_pos

    def __draw_arrow__(self, initial_position, length, text=None, 
                       text_position = 'above', text_offset=0.2, arrow = True,
                       fontsize=14, orientation='horizontal'):
        """
        Inner method.
        Draws a horizontal arrow with optional label.

        Args:
            initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
            length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
            text (string, optional): Label to display in the block.
            text_position (string, optional): Position of the optional text: {'before', 'after', 'above'}
            text_offset (float, optional): Vertical offset for the text position.
            arrow (bool, optional): Indicated if an line mush finish or not in an arrow.
            fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
            orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

        Returns:
            (Numpy.NDArray): Coordinates of output point of the arrow.
        """
        # end = (initial_position[0] + length, initial_position[1])
        head_width = 0.15 if arrow else 0

        angle = 0
        # Determine rotation angle based on orientation
        if orientation in ['horizontal', 'right']:
            angle = 0
        elif orientation == 'left':
            angle = 180
        elif orientation in ['vertical', 'down']:
            angle = -90
        elif orientation == 'up':
            angle = 90
        elif isinstance(orientation, (int, float)):
            angle = orientation
        else:
            angle = 0

        x_in, y_in = initial_position

        # Apply rotation around the connection point (x_ini, y_ini)
        trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   


        self.ax.add_patch(FancyArrow(x_in, y_in, length, 0, width=0.01,
                                length_includes_head=True, head_width=head_width, 
                                color='black', transform=trans))

        # Don't rotate text if orientation is vertical, down or up
        rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

        if text:
            # Calculate offset vector based on orientation in non-rotated coordinates
            if text_position == 'before':
                ha, va = 'right', 'center'
                offset_vector = np.array([-text_offset, 0])
                if orientation in ['vertical', 'down']:
                    ha = 'center'
                    va = 'bottom'
                elif orientation in ['up']:
                    ha = 'center'
                    va = 'top'
            elif text_position == 'after':
                ha, va = 'left', 'center'
                offset_vector = np.array([length + text_offset, 0])
                if orientation in ['vertical', 'down']:
                    ha = 'center'
                    va = 'top'
                elif orientation in ['up']:
                    ha = 'center'
                    va = 'bottom'
            elif text_position == 'above':
                ha, va = 'center', 'bottom'
                offset_vector = np.array([length / 2, text_offset])
                if orientation in ['vertical', 'down',]:
                    ha = 'left'
                    va = 'bottom'
                elif orientation in ['up']:
                    ha = 'right'
                    va = 'top'
            else:
                raise ValueError(f"Unknown text_position: {text_position}")

            self.__draw_rotated_text__(initial_position, text, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha=ha, va=va, offset=offset_vector,
                                       fontsize=fontsize)

        # Compute rotated output point
        output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
        # Compute feedback point
        feedback_pos = self.__get_rotated_pos__(initial_position, [length/2, 0], angle)
        # Add element position to the dictionary
        self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                      feedback_pos=feedback_pos)
        return output_pos

    def __draw_angled_arrow__(self, initial_position, final_position, 
                            text=None, text_offset=0.2, arrow = True, fontsize=14,
                            first_segment='horizontal', orientation='horizontal'):
        """
        Inner method.
        Draws a right-angled arrow composed of two segments, with a specified first segment orientation and optional label.

        Args:
            initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
            final_position (Numpy.NDarray or list): Coordinates of the ending point of the arrow.
            text (string, optional): Label to display in the block.
            text_offset (float, optional): Vertical offset for the text position.
            arrow (bool, optional): Indicates if it must finish or not in an arrow.
            fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
            first_segment (string, optional): Drawing order: {'horizontal', 'vertical'}
            orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

        Returns:
            (Numpy.NDArray): Coordinates of output point of the arrow.
        """
        head_width = 0.15 if arrow else 0

        angle = 0
        # Determine rotation angle based on orientation
        if orientation in ['horizontal', 'right']:
            angle = 0
        elif orientation == 'left':
            angle = 180
        elif orientation in ['vertical', 'down']:
            angle = -90
        elif orientation == 'up':
            angle = 90
        elif isinstance(orientation, (int, float)):
            angle = orientation
        else:
            angle = 0

        x_in, y_in = initial_position
        x_out, y_out = final_position
        dx = x_out - x_in
        dy = y_out - y_in

        # Apply rotation around the connection point (x_ini, y_ini)
        trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   

        if first_segment == 'horizontal':
            corner = (x_out, y_in)
        elif first_segment == 'vertical':
            corner = (x_in, y_out)
        else:
            raise ValueError("first_segment must be either 'horizontal' or 'vertical'")

        # Draw segments
        if first_segment == 'horizontal':
            if dx != 0:
                self.ax.add_patch(FancyArrow(x_in, y_in, dx, 0, width=0.01,
                        length_includes_head=True, head_width=0, 
                        color='black', transform=trans))
            if dy != 0:
                self.ax.add_patch(FancyArrow(corner[0], corner[1], 0, dy, width=0.01,
                        length_includes_head=True, head_width=head_width, 
                        color='black', transform=trans))
        else:  # first vertical
            if dy != 0:
                self.ax.add_patch(FancyArrow(x_in, y_in, 0, dy, width=0.01,
                        length_includes_head=True, head_width=0, 
                        color='black', transform=trans))
            if dx != 0:
                self.ax.add_patch(FancyArrow(corner[0], corner[1], dx, 0, width=0.01,
                        length_includes_head=True, head_width=head_width, 
                        color='black', transform=trans))

        # Don't rotate text if orientation is vertical, down or up
        rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

        # Optional text near the corner
        if text:
            # Calculate offset vector based on orientation in non-rotated coordinates
            if first_segment == 'horizontal':
                offset_vector = np.array([dx/2, text_offset])    
            else: # first vertical
                offset_vector = np.array([dx/2, dy + text_offset])    

            self.__draw_rotated_text__(initial_position, text, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha='center', va='bottom', offset=offset_vector,
                                       fontsize=fontsize)

        # Compute rotated output point
        output_pos = self.__get_rotated_pos__(final_position, [0, 0], angle)
        # Compute feedback point
        feedback_pos = self.__get_rotated_pos__(corner, [0, 0], angle)
        # Save element position
        self.__add_element_position__(input_pos=initial_position, output_pos=output_pos, feedback_pos=feedback_pos)

        return output_pos

    def __draw_combiner__(self, initial_position, height=1,
                        input_text=None, input_side='bottom', operation='mult', 
                        text_offset=0.1, signs=[None, None], fontsize=14, orientation='horizontal'):
        """
        Inner method.
        Draws a combiner block: a circle with a multiplication sign (×), sum sign (+) 
        or substraction sign (-) inside, with optional signs on each input.

        Args:
            initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
            height (float, optional): Vertical height of the block. If not entered, default `block_height` is used.
            input_text (string, optional): Label for the input arrow (below or above the arrow).
            input_side (string, optional): Side of the lateral input: {'bottom', 'top'}.
            operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'}.
            text_offset (float, optional): Vertical offset for the text position.
            signs (list, optional): Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.
            fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
            orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

        Returns:
            (Numpy.NDArray): Coordinates of output point of the combiner.
        """
        angle = 0
        # Determine rotation angle based on orientation
        if orientation in ['horizontal', 'right']:
            angle = 0
        elif orientation == 'left':
            angle = 180
        elif orientation in ['vertical', 'down']:
            angle = -90
        elif orientation == 'up':
            angle = 90
        elif isinstance(orientation, (int, float)):
            angle = orientation
        else:
            angle = 0

        x_in, y_in = initial_position

        radius = height / 4
        # Center of the block (before rotation)
        cx = x_in + radius
        cy = y_in

        # Apply rotation around the connection point (x_ini, y_ini)
        trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData  

        circle = plt.Circle((cx, cy), radius, edgecolor='black', 
                            facecolor='white', transform=trans, zorder=2)
        self.ax.add_patch(circle)

        rel_size = 0.7
        if operation == 'mult':
            # Líneas diagonales (forma de "X") dentro del círculo
            dx = radius * rel_size * np.cos(np.pi / 4)  # Escalamos un poco para que quepa dentro del círculo
            dy = radius * rel_size * np.sin(np.pi / 4)
            # Línea de 45°
            self.ax.plot([cx - dx, cx + dx], [cy - dy, cy + dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
            # Línea de 135°
            self.ax.plot([cx - dx, cx + dx], [cy + dy, cy - dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)

        elif operation == 'sum':
            dx = radius * rel_size
            dy = radius * rel_size
            # Líneas horizontales y verticales (forma de "+") dentro del círculo
            self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
            self.ax.plot([cx, cx], [cy - dy, cy + dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
        elif operation == 'dif':
            dx = radius * rel_size
            # Línea horizontal (forma de "-") dentro del círculo
            self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
        else:
            raise ValueError(f"Unknown operation: {operation}. 'operation' must be 'mult', 'sum' or 'dif'.")

        # Don't rotate text if orientation is vertical, down or up
        rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

        # Side input
        if input_side == 'bottom':
            arrow_height = height - radius
            y_init = y_in - radius - arrow_height
            offset_vector = np.array([radius, - (height + text_offset)])
            va = 'top'
            ha = 'center'
            if orientation in ['vertical', 'down']:
                ha = 'right'
                va = 'center'
            elif orientation in ['up']:
                ha = 'left'
                va = 'center'
        elif input_side == 'top':
            arrow_height = - (height - radius)
            y_init = y_in + radius - arrow_height
            offset_vector = np.array([radius, height + text_offset])
            va = 'bottom'
            ha = 'center'
            if orientation in ['vertical', 'down']:
                ha = 'left'
                va = 'center'
            elif orientation in ['up']:
                ha = 'right'
                va = 'center'
        else:
            raise ValueError(f"Unknown input_side: {input_side}. 'input_side' must be 'bottom' or 'top'.")

        # Show signs on each input if not None
        if signs[0] is not None:
            self.__draw_rotated_text__(initial_position, signs[0], 
                                    angle=angle, rotate_text=rotate_text,
                                    ha=ha, va=va, 
                                    fontsize=fontsize, offset=[-radius, 1.5*radius])
        if signs[1] is not None:
            self.__draw_rotated_text__(initial_position, signs[1], 
                                    angle=angle, rotate_text=rotate_text,
                                    ha=ha, va=va, 
                                    fontsize=fontsize, offset=[0, -1.5*radius])

        self.ax.add_patch(FancyArrow(cx, y_init, 0, arrow_height, width=0.01,
                                length_includes_head=True, head_width=0.15, 
                                color='black', transform=trans))
        if input_text is not None:
            self.__draw_rotated_text__(initial_position, input_text, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha=ha, va=va, 
                                       fontsize=fontsize, offset=offset_vector)

        # Compute rotated output point
        output_pos = self.__get_rotated_pos__(initial_position, [2 * radius, 0], angle)
        # Compute feedback point
        feedback_pos = self.__get_rotated_pos__(initial_position, [radius, y_init - y_in + arrow_height], angle)
        # Add element position to the dictionary
        self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                      feedback_pos=feedback_pos)
        return output_pos

    def __draw_mult_combiner__(self, initial_position, length, inputs, 
                               operation='sum', orientation='horizontal'):
        """
        Inner method.
        Draws a summation or multiplication block with multiple inputs distributed 
        along the left edge of a circle, from pi/2 to 3*pi/2. Inputs can have a sign.

        Args:
            initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
            length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
            inputs (list of str): Thread names to combine.
            operation (string, optional): Operation of the combiner: {'mult', 'sum'}.
            orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

        Returns:
            (Numpy.NDArray): Coordinates of output point of the combiner.
        """
        angle = 0
        # Determine rotation angle based on orientation
        if orientation in ['horizontal', 'right']:
            angle = 0
        elif orientation == 'left':
            angle = 180
        elif orientation in ['vertical', 'down']:
            angle = -90
        elif orientation == 'up':
            angle = 90
        elif isinstance(orientation, (int, float)):
            angle = orientation
        else:
            angle = 0

        # If position is 'auto', obtain head position
        if isinstance(initial_position, str) and initial_position == 'auto':
            # Get head positions of input threads
            thread_input_pos = np.array([self.thread_positions[key] for key in inputs])
            x_in = np.max(thread_input_pos[:, 0])
            y_in = np.mean(thread_input_pos[:,1])
            initial_position = [x_in, y_in]
        # If position is given, use it
        else:
            x_in, y_in = initial_position

        radius = length / 4
        cx = x_in + length - radius
        cy = y_in

        # Apply rotation around the connection point (x_ini, y_ini)
        trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData  

        # Circle
        circle = plt.Circle((cx, cy), radius, edgecolor='black', 
                            facecolor='white', transform=trans, zorder=2)
        self.ax.add_patch(circle)

        # Draw symbol inside circle depending on operation
        rel_size = 0.7
        if operation == 'mult':
            # "X" inside circle
            dx = radius * rel_size * np.cos(np.pi / 4)  # Escalamos un poco para que quepa dentro del círculo
            dy = radius * rel_size * np.sin(np.pi / 4)
            #  45° line
            self.ax.plot([cx - dx, cx + dx], [cy - dy, cy + dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
            # 135° line
            self.ax.plot([cx - dx, cx + dx], [cy + dy, cy - dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
        elif operation == 'sum':
            dx = radius * rel_size
            dy = radius * rel_size
            # "+" inside circle
            self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
            self.ax.plot([cx, cx], [cy - dy, cy + dy], color='black', 
                         linewidth=2, transform=trans, zorder=3)
        else:
            raise ValueError(f"Unknown operation: {operation}. Use 'sum' or 'mult'.")

        # Get rotation matrix
        rot_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]

        n = len(thread_input_pos)
        angles = np.linspace(5* np.pi / 8, 11 * np.pi / 8, n)

        arrow_width = 0.01
        arrow_head_width = 0.15

        # Input arrows
        for i, inp in enumerate(thread_input_pos):
            xi, yi = inp[:2]

            x_edge = cx + radius * np.cos(angles[i])
            y_edge = cy + radius * np.sin(angles[i])

            dx = x_edge - x_in
            dy = y_edge - y_in
            offset_vec = [dx, dy]

            # Rotated offset vector with respect to initial_position of element
            dx_rot, dy_rot = rot_matrix @ offset_vec
            # Rotated offset vector with respect to initial position of arrow
            dx_rot_rel = dx_rot - xi + x_in
            dy_rot_rel = dy_rot - yi + y_in

            self.ax.add_patch(FancyArrow(
                xi, yi, dx_rot_rel, dy_rot_rel,
                width=arrow_width,
                length_includes_head=True,
                head_width=arrow_head_width,
                color='black', transform=self.ax.transData, zorder=1
            ))

        # Compute rotated output point
        output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
        # Compute feedback point
        feedback_pos = self.__get_rotated_pos__(initial_position, [length - radius, -radius], angle)
        # Add element position to the dictionary
        self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                      feedback_pos=feedback_pos)
        return output_pos

    def add(self,name, kind='block', thread='main', position=None, debug=False, **kwargs):
        """
        Adds an element to the block diagram at the current or specified position of a given thread.

        This is the main interface for constructing diagrams by adding components such as blocks, arrows,
        inputs, outputs, combiners, and connectors. The `kind` parameter determines the type of element,
        and each type accepts specific keyword arguments listed below.

        Args:
            name (str): Main label or identifier for the element.
            kind (str, optional): Type of element. One of:
                - 'block': Rectangular block.
                - 'arrow': Straight line with ending arrow.
                - 'angled_arrow': Rect angle line with or without ending arrow.
                - 'input': Arrow with text before it.
                - 'output': Arrow with text after it.
                - 'line': Straight line without arrow ending.
                - 'combiner': Circle with (x), (+) or (-) and additional input.
                - 'mult_combiner': Combiner with multiple inputs.
            thread (str, optional): Thread identifier.
            position (tuple or str or None, optional): (x, y) position, 'auto' (for mult_combiner), or None to use current thread position.
            debug (bool, optional): If True, prints thread positions after placing the element.

        The `**kwargs` vary depending on the `kind`:

        - **kind = 'block'**:
            - text (str, optional): Label inside the block (defaults to `name`).
            - text_above (str, optional): Text above the block.
            - text_below (str, optional): Text below the block.
            - text_offset (float, optional): Offset for above/below text (defaults to 0.1).
            - input_text (str, optional): Label for input arrow.
            - input_side (str, optional): Side of a second optional input: {'top', 'bottom'} (defaults to `None`)
            - length (float, optional): Block length (defaults to `self.block_length`).
            - height (float, optional): Block height (defaults to `self.block_height`)..
            - linestyle (str, optional): Block border line style (defaults to `-`).
            - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
            - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

        - **kind = 'arrow'**, **'input'**, **'output'** or **'line'**:
            - text (str, optional): Text on the arrow or line (defaults to `name`).
            - text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
            - text_offset (float, optional): Offset for text (defaults to 0.1).
            - length (float, optional): Arrow or line length (defaults to `self.block_length`).
            - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
            - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

        - **kind = 'angled_arrow'**:
            - text (str, optional): Text on the arrow or line (defaults to `name`).
            - final_pos (Numpy.NDarray or list): Coordinates of the ending point of the arrow.
            - text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
            - text_offset (float, optional): Offset for text (defaults to 0.1).
            - arrow (bool, optional): Indicates if it must finish or not in an arrow.
            - first_segment (string, optional): Drawing order: {'horizontal', 'vertical'}
            - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
            - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

        - **kind = 'combiner'**:
            - operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaultds to 'mult').
            - height (float, optional): Vertical height of the block. (defaults to `self.block_height`).
            - input_side (string, optional): Side of the lateral input: {'bottom', 'top'} (defaults to 'bottom').
            - input_text (string, optional): Label for the input arrow (below or above the arrow).
            - text_offset (float, optional): Offset for text (defaults to 0.1).
            - signs (list, optional): Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.
            - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
            - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

        - **kind = 'mult_combiner'**:
            - operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaults to 'mult').
            - length (float, optional): Total element length (defaults to `self.block_length`).
            - inputs (list of str): Thread names to combine.
            - operation (string, optional): Operation of the combiner: {'mult', 'sum'} (defaults to 'sum').
            - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
            - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

        Examples:
            >>> db = DiagramBuilder()
            >>> db.add("x(t)", kind="input")
            >>> db.add("H(s)", kind="block")
            >>> db.add("y(t)", kind="output")
        """

        # If position is 'auto' (draw_mult_combiner), position is calculated inside that method
        if isinstance(position, str) and position == 'auto':
            initial_pos = 'auto'
        # If input argument position is given and not 'auto', element position is asigned to position argument value
        elif position is not None:
            initial_pos = list(position)
        # If not given
        else:
            # If thread already exists, element position is asigned from thread head
            if thread in self.thread_positions:
                initial_pos = self.thread_positions[thread]
            # If doesn't exist
            else:
                initial_pos = [0, 0]

        if kind == 'arrow':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_position': 'above',
                'arrow': True,
                'text_offset': 0.1,
                'length': self.block_length,
                'fontsize': self.fontsize,
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_arrow__(initial_pos, **block_args)

        elif kind == 'angled_arrow':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_offset': 0.1,
                'fontsize': self.fontsize,
                'orientation': 'horizontal',
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_angled_arrow__(initial_pos, **block_args)

        elif kind == 'input':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_position': 'before',
                'arrow': True,
                'text_offset': 0.1,
                'length': self.block_length,
                'fontsize': self.fontsize,
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_arrow__(initial_pos, **block_args)

        elif kind == 'output':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_position': 'after',
                'arrow': True,
                'text_offset': 0.1,
                'length': self.block_length,
                'fontsize': self.fontsize,
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_arrow__(initial_pos, **block_args)

        elif kind == 'line':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_position': 'above',
                'arrow': False,
                'text_offset': 0.1,
                'length': self.block_length,
                'fontsize': self.fontsize,
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_arrow__(initial_pos, **block_args)

        elif kind == 'block':
            # Default arguments
            default_kwargs = {
                'text': name,
                'text_above': None,
                'text_below': None,
                'text_offset': 0.1,
                'input_text': None,
                'input_side': None,
                'length': self.block_length,
                'height': self.block_height,
                'fontsize': self.fontsize,
                'linestyle': '-',
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_block__(initial_pos, **block_args)

        elif kind == 'combiner':
            # Default arguments
            default_kwargs = {
                'height': self.block_height,
                'fontsize': self.fontsize,
                'operation': 'mult',
                'input_side': 'bottom',
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_combiner__(initial_pos, **block_args)

        elif kind == 'mult_combiner':
            # Default arguments
            default_kwargs = {
                'length': self.block_length,
                'operation': 'mult',
                'orientation': 'horizontal'
            }
            # Overrides default arguments with provided ones
            block_args = {**default_kwargs, **kwargs}
            # Function call
            final_pos = self.__draw_mult_combiner__(initial_pos, **block_args)


        elif kind == 'output':
            length=kwargs.get('length', self.block_length)
            final_pos = self.__draw_io_arrow__(initial_pos, length=length, text=kwargs.get('text', name),
                          io='output', fontsize=self.fontsize)

        else:
            raise ValueError(f"Unknown block type: {kind}")

        # Update head position of thread
        self.thread_positions[thread] = final_pos

        if debug:
            self.__print_threads__()

    def get_current_element(self):
        """
        Returns the current element index (last added).

        Returns:
            (int): Index of the last added element.
        """
        return self.current_element

    def get_position(self, element=None):
        """
        Returns the positions of the specified element index. If no element specified, last added element is used.
        The return is a dictinoary with coordinates of `input_pos`, `output_pos` and `feedback_pos` (feedback port coordinates).

        Args:
            element (int, optional): index of the element.

        Returns:
            (dict of Tuples): Dictionary with three 2-element tuples: `input_pos`, `output_pos` and `feedback_pos`.
        """
        if element is None:
            return self.element_positions[self.current_element]
        elif element <= self.current_element:
            return self.element_positions[element]
        else:
            raise ValueError(f"Element '{element}' not found.")

    def get_thread_position(self, thread='main'):
        """
        Returns the current output position of the specified thread.

        Args:
            thread (str, optional): Thread identifier.
        """
        if thread in self.thread_positions:
            return self.thread_positions[thread]
        else:
            raise ValueError(f"Thread '{thread}' not found.")


    def show(self, margin=0.5, scale=1.0, savepath=None):
        """
        Displays the current diagram or saves it to a file.

        Adjusts the view to fit the full diagram with an optional margin and scaling factor.
        If no elements have been drawn, simply displays an empty figure.

        Args:
            margin (float, optional): Margin to add around the diagram (in data units).
            scale (float, optional): Scaling factor for the figure size.
            savepath (str, optional): If provided, saves the figure to the specified path (e.g., 'diagram.png' or 'diagram.pdf').
                                      If None, the diagram is shown in an interactive window.

        """
        bbox = self.__get_bbox__()
        if bbox is None:
            plt.show()
            return

        x0 = bbox.x0 - margin
        x1 = bbox.x1 + margin
        y0 = bbox.y0 - margin
        y1 = bbox.y1 + margin

        width = x1 - x0
        height = y1 - y0

        fig_width = width * scale
        fig_height = height * scale
        self.fig.set_size_inches(fig_width, fig_height)

        self.ax.set_xlim(x0, x1)
        self.ax.set_ylim(y0, y1)
        self.ax.set_aspect("equal", adjustable="box")
        self.ax.set_position([0, 0, 1, 1])
        self.ax.axis("off")

        if savepath:
            self.fig.savefig(savepath, bbox_inches='tight', dpi=self.fig.dpi, transparent=False, facecolor='white')
            print(f"Saved in: {savepath}")
        else:
            plt.show()

__add_element_position__(input_pos, output_pos, feedback_pos)

Inner method. Adds a new element with the given input, output and feedback positions.

Parameters:

Name Type Description Default
input_pos NDArray or list

Input position of the block.

required
output_pos NDArray or list

Output position of the block.

required
feedback_pos NDArray or list

Feedback port position of the block.

required
Source code in signalblocks\DiagramBuilder.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def __add_element_position__(self, input_pos: Tuple[float, float], 
                             output_pos: Tuple[float, float], 
                             feedback_pos: Tuple[float, float]):
    """
    Inner method.
    Adds a new element with the given input, output and feedback positions.

    Args:
        input_pos (Numpy.NDArray or list): Input position of the block.
        output_pos (Numpy.NDArray or list): Output position of the block.
        feedback_pos (Numpy.NDArray or list): Feedback port position of the block.
    """
    self.current_element += 1

    self.element_positions[self.current_element] = ElementPosition(
        input_pos=input_pos,
        output_pos=output_pos,
        feedback_pos=feedback_pos
    )

__draw_angled_arrow__(initial_position, final_position, text=None, text_offset=0.2, arrow=True, fontsize=14, first_segment='horizontal', orientation='horizontal')

Inner method. Draws a right-angled arrow composed of two segments, with a specified first segment orientation and optional label.

Parameters:

Name Type Description Default
initial_position NDarray or list

Coordinates of the starting point of the arrow.

required
final_position NDarray or list

Coordinates of the ending point of the arrow.

required
text string

Label to display in the block.

None
text_offset float

Vertical offset for the text position.

0.2
arrow bool

Indicates if it must finish or not in an arrow.

True
fontsize int

font size of the text inside the block. If not entered, default fontsize is used.

14
first_segment string

Drawing order: {'horizontal', 'vertical'}

'horizontal'
orientation string or float

Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

'horizontal'

Returns:

Type Description
NDArray

Coordinates of output point of the arrow.

Source code in signalblocks\DiagramBuilder.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def __draw_angled_arrow__(self, initial_position, final_position, 
                        text=None, text_offset=0.2, arrow = True, fontsize=14,
                        first_segment='horizontal', orientation='horizontal'):
    """
    Inner method.
    Draws a right-angled arrow composed of two segments, with a specified first segment orientation and optional label.

    Args:
        initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
        final_position (Numpy.NDarray or list): Coordinates of the ending point of the arrow.
        text (string, optional): Label to display in the block.
        text_offset (float, optional): Vertical offset for the text position.
        arrow (bool, optional): Indicates if it must finish or not in an arrow.
        fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
        first_segment (string, optional): Drawing order: {'horizontal', 'vertical'}
        orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

    Returns:
        (Numpy.NDArray): Coordinates of output point of the arrow.
    """
    head_width = 0.15 if arrow else 0

    angle = 0
    # Determine rotation angle based on orientation
    if orientation in ['horizontal', 'right']:
        angle = 0
    elif orientation == 'left':
        angle = 180
    elif orientation in ['vertical', 'down']:
        angle = -90
    elif orientation == 'up':
        angle = 90
    elif isinstance(orientation, (int, float)):
        angle = orientation
    else:
        angle = 0

    x_in, y_in = initial_position
    x_out, y_out = final_position
    dx = x_out - x_in
    dy = y_out - y_in

    # Apply rotation around the connection point (x_ini, y_ini)
    trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   

    if first_segment == 'horizontal':
        corner = (x_out, y_in)
    elif first_segment == 'vertical':
        corner = (x_in, y_out)
    else:
        raise ValueError("first_segment must be either 'horizontal' or 'vertical'")

    # Draw segments
    if first_segment == 'horizontal':
        if dx != 0:
            self.ax.add_patch(FancyArrow(x_in, y_in, dx, 0, width=0.01,
                    length_includes_head=True, head_width=0, 
                    color='black', transform=trans))
        if dy != 0:
            self.ax.add_patch(FancyArrow(corner[0], corner[1], 0, dy, width=0.01,
                    length_includes_head=True, head_width=head_width, 
                    color='black', transform=trans))
    else:  # first vertical
        if dy != 0:
            self.ax.add_patch(FancyArrow(x_in, y_in, 0, dy, width=0.01,
                    length_includes_head=True, head_width=0, 
                    color='black', transform=trans))
        if dx != 0:
            self.ax.add_patch(FancyArrow(corner[0], corner[1], dx, 0, width=0.01,
                    length_includes_head=True, head_width=head_width, 
                    color='black', transform=trans))

    # Don't rotate text if orientation is vertical, down or up
    rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

    # Optional text near the corner
    if text:
        # Calculate offset vector based on orientation in non-rotated coordinates
        if first_segment == 'horizontal':
            offset_vector = np.array([dx/2, text_offset])    
        else: # first vertical
            offset_vector = np.array([dx/2, dy + text_offset])    

        self.__draw_rotated_text__(initial_position, text, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha='center', va='bottom', offset=offset_vector,
                                   fontsize=fontsize)

    # Compute rotated output point
    output_pos = self.__get_rotated_pos__(final_position, [0, 0], angle)
    # Compute feedback point
    feedback_pos = self.__get_rotated_pos__(corner, [0, 0], angle)
    # Save element position
    self.__add_element_position__(input_pos=initial_position, output_pos=output_pos, feedback_pos=feedback_pos)

    return output_pos

__draw_arrow__(initial_position, length, text=None, text_position='above', text_offset=0.2, arrow=True, fontsize=14, orientation='horizontal')

Inner method. Draws a horizontal arrow with optional label.

Parameters:

Name Type Description Default
initial_position NDarray or list

Coordinates of the starting point of the arrow.

required
length float

Horizontal length of the block. If not entered, default block_length is used.

required
text string

Label to display in the block.

None
text_position string

Position of the optional text: {'before', 'after', 'above'}

'above'
text_offset float

Vertical offset for the text position.

0.2
arrow bool

Indicated if an line mush finish or not in an arrow.

True
fontsize int

font size of the text inside the block. If not entered, default fontsize is used.

14
orientation string or float

Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

'horizontal'

Returns:

Type Description
NDArray

Coordinates of output point of the arrow.

Source code in signalblocks\DiagramBuilder.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def __draw_arrow__(self, initial_position, length, text=None, 
                   text_position = 'above', text_offset=0.2, arrow = True,
                   fontsize=14, orientation='horizontal'):
    """
    Inner method.
    Draws a horizontal arrow with optional label.

    Args:
        initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
        length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
        text (string, optional): Label to display in the block.
        text_position (string, optional): Position of the optional text: {'before', 'after', 'above'}
        text_offset (float, optional): Vertical offset for the text position.
        arrow (bool, optional): Indicated if an line mush finish or not in an arrow.
        fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
        orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

    Returns:
        (Numpy.NDArray): Coordinates of output point of the arrow.
    """
    # end = (initial_position[0] + length, initial_position[1])
    head_width = 0.15 if arrow else 0

    angle = 0
    # Determine rotation angle based on orientation
    if orientation in ['horizontal', 'right']:
        angle = 0
    elif orientation == 'left':
        angle = 180
    elif orientation in ['vertical', 'down']:
        angle = -90
    elif orientation == 'up':
        angle = 90
    elif isinstance(orientation, (int, float)):
        angle = orientation
    else:
        angle = 0

    x_in, y_in = initial_position

    # Apply rotation around the connection point (x_ini, y_ini)
    trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   


    self.ax.add_patch(FancyArrow(x_in, y_in, length, 0, width=0.01,
                            length_includes_head=True, head_width=head_width, 
                            color='black', transform=trans))

    # Don't rotate text if orientation is vertical, down or up
    rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

    if text:
        # Calculate offset vector based on orientation in non-rotated coordinates
        if text_position == 'before':
            ha, va = 'right', 'center'
            offset_vector = np.array([-text_offset, 0])
            if orientation in ['vertical', 'down']:
                ha = 'center'
                va = 'bottom'
            elif orientation in ['up']:
                ha = 'center'
                va = 'top'
        elif text_position == 'after':
            ha, va = 'left', 'center'
            offset_vector = np.array([length + text_offset, 0])
            if orientation in ['vertical', 'down']:
                ha = 'center'
                va = 'top'
            elif orientation in ['up']:
                ha = 'center'
                va = 'bottom'
        elif text_position == 'above':
            ha, va = 'center', 'bottom'
            offset_vector = np.array([length / 2, text_offset])
            if orientation in ['vertical', 'down',]:
                ha = 'left'
                va = 'bottom'
            elif orientation in ['up']:
                ha = 'right'
                va = 'top'
        else:
            raise ValueError(f"Unknown text_position: {text_position}")

        self.__draw_rotated_text__(initial_position, text, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha=ha, va=va, offset=offset_vector,
                                   fontsize=fontsize)

    # Compute rotated output point
    output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
    # Compute feedback point
    feedback_pos = self.__get_rotated_pos__(initial_position, [length/2, 0], angle)
    # Add element position to the dictionary
    self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                  feedback_pos=feedback_pos)
    return output_pos

__draw_block__(initial_position, text=None, text_below=None, text_above=None, text_offset=0.1, input_text=None, input_side=None, length=1.5, height=1, fontsize=14, linestyle='-', orientation='horizontal')

Inner method. Draws a rectangular block with centered text, optional texts below and/or above and optional input arrow with text.

Parameters:

Name Type Description Default
initial_position NDarray or list

Coordinates of the center position of the input edge of the block.

required
text string

Label to display in the block.

None
text_below string

Label to display below the block.

None
text_above string

Label to display above the block.

None
text_offset float

Vertical offset for the text position.

0.1
input_text string

Label for the optional input arrow (below or above the block).

None
input_side string

Side to place the input arrow: {'bottom', 'top', None}

None
length float

Horizontal length of the block. If not entered, default block_length is used.

1.5
height float

Vertical height of the block. If not entered, default block_height is used.

1
fontsize int

font size of the text inside the block. If not entered, default fontsize is used.

14
linestyle string

linestyle of the block edge: {'-, '--, ':', '-.'}.

'-'
orientation string or float

Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

'horizontal'

Returns:

Type Description
NDArray

Coordinates of the center position of the output edge of the block.

Source code in signalblocks\DiagramBuilder.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def __draw_block__(self, initial_position, text=None, text_below=None, 
                   text_above=None, text_offset=0.1, input_text=None, 
                   input_side=None, length=1.5, height=1, fontsize=14, 
                   linestyle='-', orientation='horizontal'):
    """
    Inner method.
    Draws a rectangular block with centered text, optional texts below and/or above and optional input arrow with text.

    Args:
        initial_position (Numpy.NDarray or list): Coordinates of the center position of the input edge of the block.
        text (string, optional): Label to display in the block.
        text_below (string, optional): Label to display below the block.
        text_above (string, optional): Label to display above the block.
        text_offset (float, optional): Vertical offset for the text position.
        input_text (string, optional): Label for the optional input arrow (below or above the block).
        input_side (string, optional): Side to place the input arrow: {'bottom', 'top', None}
        length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
        height (float, optional): Vertical height of the block. If not entered, default `block_height` is used.
        fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
        linestyle (string, optional): linestyle of the block edge: {'-, '--, ':', '-.'}.
        orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

    Returns:
        (Numpy.NDArray): Coordinates of the center position of the output edge of the block.
    """
    # Parameters validation
    if input_side not in (None, 'top', 'bottom'):
        raise ValueError(f"Invalid input_side: {input_side}. Use 'top' or 'bottom'.")
    if orientation not in (None, 'horizontal', 'vertical', 'up', 'down', 'left', 'right'):
        if isinstance(orientation, (int, float)):
            pass
        else:
            raise ValueError(f"Invalid orientation: {orientation}. Use 'horizontal', 'vertical', 'up', 'down', 'left', or 'right'.")
    if linestyle not in (None, '-', '--', ':', '-.', 'solid', 'dashed', 'dotted', 'dashdot'):
        raise ValueError(f"Invalid linestyle: {linestyle}. Use '-', '--', ':', '-.', 'solid', 'dashed', 'dotted', or 'dashdot'.")
    if not isinstance(length, (int, float)) or length <= 0:
        raise ValueError(f"Invalid length: {length}. Length must be a positive number.")
    if not isinstance(height, (int, float)) or height <= 0:
        raise ValueError(f"Invalid height: {height}. Height must be a positive number.")
    if not isinstance(text_offset, (int, float)):
        raise ValueError(f"Invalid text_offset: {text_offset}. Text offset must be a number.")
    if not isinstance(fontsize, (int, float)):
        raise ValueError(f"Invalid fontsize: {fontsize}. Font size must be a number.")


    # Determine rotation angle based on orientation
    if orientation in ['horizontal', 'right']:
        angle = 0
    elif orientation == 'left':
        angle = 180
    elif orientation in ['vertical', 'down']:
        angle = -90
    elif orientation == 'up':
        angle = 90
    elif isinstance(orientation, (int, float)):
        angle = orientation
    else:
        angle = 0

    x_in, y_in = initial_position

    # Bottom-left corner of the block (before rotation)
    x0 = x_in
    y0 = y_in - height / 2

    # Center of the block (before rotation)
    cx = x_in + length / 2
    cy = y_in

    # Apply the rotation around the connection point (x_ini, y_ini)
    trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData   

    self.ax.add_patch(Rectangle((x0, y0), length, height, 
                                edgecolor='black', facecolor='none', 
                                linestyle=linestyle, transform=trans))
    # Don't rotate text if orientation is vertical, down or up
    rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

    # Draw text inside the block
    if text is not None:
        offset_vector = np.array([length / 2, 0])
        self.__draw_rotated_text__(initial_position, text, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha='center', va='center', 
                                   fontsize=fontsize, offset=offset_vector)

    # Draw text above the block
    if text_above is not None:
        if orientation in ['vertical', 'down']:
            ha = 'left'
            va = 'center'
        elif orientation in ['up']:
            ha = 'right'
            va = 'center'
        else:
            ha = 'center'
            va = 'bottom'
        offset_vector = np.array([length / 2, height / 2 + text_offset])
        self.__draw_rotated_text__(initial_position, text_above, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha=ha, va=va, 
                                   fontsize=fontsize, offset=offset_vector)

    # Draw text below the block
    if text_below is not None:
        if orientation in ['vertical', 'down']:
            ha = 'right'
            va = 'center'
        elif orientation in ['up']:
            ha = 'left'
            va = 'center'
        else:
            ha = 'center'
            va = 'top'
        offset_vector = np.array([length / 2, - height / 2 - text_offset])
        self.__draw_rotated_text__(initial_position, text_below, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha=ha, va=va, 
                                   fontsize=fontsize, offset=offset_vector)

    if input_side is not None:
        if input_side == 'bottom':
            arrow_height = 0.75 * height
            y_init = y0 - arrow_height
            offset_vector = np.array([length / 2, - height /2 - arrow_height - text_offset])
            va = 'top'
            ha = 'center'
            if orientation in ['vertical', 'down']:
                ha = 'right'
                va = 'center'
            elif orientation in ['up']:
                ha = 'left'
                va = 'center'
            elif orientation in ['left']:
                ha = 'center'
                va = 'bottom'
        elif input_side == 'top':
            arrow_height = - 0.75 * height
            y_init = y0 + height - arrow_height
            offset_vector = np.array([length / 2, height /2 - arrow_height + text_offset])
            va = 'bottom'
            ha = 'center'
            if orientation in ['vertical', 'down']:
                ha = 'left'
                va = 'center'
            elif orientation in ['up']:
                ha = 'right'
                va = 'center'
            elif orientation in ['left']:
                ha = 'center'
                va = 'top'
        else:
            raise ValueError(f"Unknown input side: {input_side}. Use 'bottom' or 'top'.")   

        self.ax.add_patch(FancyArrow(cx, y_init, 0, arrow_height, width=0.01,
                                length_includes_head=True, head_width=0.15, 
                                color='black', transform=trans))
        if input_text is not None:

            self.__draw_rotated_text__(initial_position, input_text, 
                                       angle=angle, rotate_text=rotate_text,
                                       ha=ha, va=va, 
                                       fontsize=fontsize, offset=offset_vector)

    # Compute rotated output point
    output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
    # Compute feedback point
    feedback_pos = self.__get_rotated_pos__(initial_position, [length/2, -height/2], angle)
    # Add element position to the dictionary
    self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                  feedback_pos=feedback_pos)
    return output_pos

__draw_combiner__(initial_position, height=1, input_text=None, input_side='bottom', operation='mult', text_offset=0.1, signs=[None, None], fontsize=14, orientation='horizontal')

Inner method. Draws a combiner block: a circle with a multiplication sign (×), sum sign (+) or substraction sign (-) inside, with optional signs on each input.

Parameters:

Name Type Description Default
initial_position NDarray or list

Coordinates of the starting point of the arrow.

required
height float

Vertical height of the block. If not entered, default block_height is used.

1
input_text string

Label for the input arrow (below or above the arrow).

None
input_side string

Side of the lateral input: {'bottom', 'top'}.

'bottom'
operation string

Operation of the combiner: {'mult', 'sum', 'dif'}.

'mult'
text_offset float

Vertical offset for the text position.

0.1
signs list

Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.

[None, None]
fontsize int

font size of the text inside the block. If not entered, default fontsize is used.

14
orientation string or float

Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

'horizontal'

Returns:

Type Description
NDArray

Coordinates of output point of the combiner.

Source code in signalblocks\DiagramBuilder.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
def __draw_combiner__(self, initial_position, height=1,
                    input_text=None, input_side='bottom', operation='mult', 
                    text_offset=0.1, signs=[None, None], fontsize=14, orientation='horizontal'):
    """
    Inner method.
    Draws a combiner block: a circle with a multiplication sign (×), sum sign (+) 
    or substraction sign (-) inside, with optional signs on each input.

    Args:
        initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
        height (float, optional): Vertical height of the block. If not entered, default `block_height` is used.
        input_text (string, optional): Label for the input arrow (below or above the arrow).
        input_side (string, optional): Side of the lateral input: {'bottom', 'top'}.
        operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'}.
        text_offset (float, optional): Vertical offset for the text position.
        signs (list, optional): Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.
        fontsize (int, optional): font size of the text inside the block. If not entered, default `fontsize` is used.
        orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

    Returns:
        (Numpy.NDArray): Coordinates of output point of the combiner.
    """
    angle = 0
    # Determine rotation angle based on orientation
    if orientation in ['horizontal', 'right']:
        angle = 0
    elif orientation == 'left':
        angle = 180
    elif orientation in ['vertical', 'down']:
        angle = -90
    elif orientation == 'up':
        angle = 90
    elif isinstance(orientation, (int, float)):
        angle = orientation
    else:
        angle = 0

    x_in, y_in = initial_position

    radius = height / 4
    # Center of the block (before rotation)
    cx = x_in + radius
    cy = y_in

    # Apply rotation around the connection point (x_ini, y_ini)
    trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData  

    circle = plt.Circle((cx, cy), radius, edgecolor='black', 
                        facecolor='white', transform=trans, zorder=2)
    self.ax.add_patch(circle)

    rel_size = 0.7
    if operation == 'mult':
        # Líneas diagonales (forma de "X") dentro del círculo
        dx = radius * rel_size * np.cos(np.pi / 4)  # Escalamos un poco para que quepa dentro del círculo
        dy = radius * rel_size * np.sin(np.pi / 4)
        # Línea de 45°
        self.ax.plot([cx - dx, cx + dx], [cy - dy, cy + dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
        # Línea de 135°
        self.ax.plot([cx - dx, cx + dx], [cy + dy, cy - dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)

    elif operation == 'sum':
        dx = radius * rel_size
        dy = radius * rel_size
        # Líneas horizontales y verticales (forma de "+") dentro del círculo
        self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
        self.ax.plot([cx, cx], [cy - dy, cy + dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
    elif operation == 'dif':
        dx = radius * rel_size
        # Línea horizontal (forma de "-") dentro del círculo
        self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
    else:
        raise ValueError(f"Unknown operation: {operation}. 'operation' must be 'mult', 'sum' or 'dif'.")

    # Don't rotate text if orientation is vertical, down or up
    rotate_text = False if orientation in ['vertical', 'down', 'up', 'left'] else True

    # Side input
    if input_side == 'bottom':
        arrow_height = height - radius
        y_init = y_in - radius - arrow_height
        offset_vector = np.array([radius, - (height + text_offset)])
        va = 'top'
        ha = 'center'
        if orientation in ['vertical', 'down']:
            ha = 'right'
            va = 'center'
        elif orientation in ['up']:
            ha = 'left'
            va = 'center'
    elif input_side == 'top':
        arrow_height = - (height - radius)
        y_init = y_in + radius - arrow_height
        offset_vector = np.array([radius, height + text_offset])
        va = 'bottom'
        ha = 'center'
        if orientation in ['vertical', 'down']:
            ha = 'left'
            va = 'center'
        elif orientation in ['up']:
            ha = 'right'
            va = 'center'
    else:
        raise ValueError(f"Unknown input_side: {input_side}. 'input_side' must be 'bottom' or 'top'.")

    # Show signs on each input if not None
    if signs[0] is not None:
        self.__draw_rotated_text__(initial_position, signs[0], 
                                angle=angle, rotate_text=rotate_text,
                                ha=ha, va=va, 
                                fontsize=fontsize, offset=[-radius, 1.5*radius])
    if signs[1] is not None:
        self.__draw_rotated_text__(initial_position, signs[1], 
                                angle=angle, rotate_text=rotate_text,
                                ha=ha, va=va, 
                                fontsize=fontsize, offset=[0, -1.5*radius])

    self.ax.add_patch(FancyArrow(cx, y_init, 0, arrow_height, width=0.01,
                            length_includes_head=True, head_width=0.15, 
                            color='black', transform=trans))
    if input_text is not None:
        self.__draw_rotated_text__(initial_position, input_text, 
                                   angle=angle, rotate_text=rotate_text,
                                   ha=ha, va=va, 
                                   fontsize=fontsize, offset=offset_vector)

    # Compute rotated output point
    output_pos = self.__get_rotated_pos__(initial_position, [2 * radius, 0], angle)
    # Compute feedback point
    feedback_pos = self.__get_rotated_pos__(initial_position, [radius, y_init - y_in + arrow_height], angle)
    # Add element position to the dictionary
    self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                  feedback_pos=feedback_pos)
    return output_pos

__draw_mult_combiner__(initial_position, length, inputs, operation='sum', orientation='horizontal')

Inner method. Draws a summation or multiplication block with multiple inputs distributed along the left edge of a circle, from pi/2 to 3*pi/2. Inputs can have a sign.

Parameters:

Name Type Description Default
initial_position NDarray or list

Coordinates of the starting point of the arrow.

required
length float

Horizontal length of the block. If not entered, default block_length is used.

required
inputs list of str

Thread names to combine.

required
operation string

Operation of the combiner: {'mult', 'sum'}.

'sum'
orientation string or float

Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

'horizontal'

Returns:

Type Description
NDArray

Coordinates of output point of the combiner.

Source code in signalblocks\DiagramBuilder.py
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
def __draw_mult_combiner__(self, initial_position, length, inputs, 
                           operation='sum', orientation='horizontal'):
    """
    Inner method.
    Draws a summation or multiplication block with multiple inputs distributed 
    along the left edge of a circle, from pi/2 to 3*pi/2. Inputs can have a sign.

    Args:
        initial_position (Numpy.NDarray or list): Coordinates of the starting point of the arrow.
        length (float, optional): Horizontal length of the block. If not entered, default `block_length` is used.
        inputs (list of str): Thread names to combine.
        operation (string, optional): Operation of the combiner: {'mult', 'sum'}.
        orientation (string or float, optional): Direction of the block: {'horizontal', 'vertical', 'up', 'down', 'left', 'right', angle}.

    Returns:
        (Numpy.NDArray): Coordinates of output point of the combiner.
    """
    angle = 0
    # Determine rotation angle based on orientation
    if orientation in ['horizontal', 'right']:
        angle = 0
    elif orientation == 'left':
        angle = 180
    elif orientation in ['vertical', 'down']:
        angle = -90
    elif orientation == 'up':
        angle = 90
    elif isinstance(orientation, (int, float)):
        angle = orientation
    else:
        angle = 0

    # If position is 'auto', obtain head position
    if isinstance(initial_position, str) and initial_position == 'auto':
        # Get head positions of input threads
        thread_input_pos = np.array([self.thread_positions[key] for key in inputs])
        x_in = np.max(thread_input_pos[:, 0])
        y_in = np.mean(thread_input_pos[:,1])
        initial_position = [x_in, y_in]
    # If position is given, use it
    else:
        x_in, y_in = initial_position

    radius = length / 4
    cx = x_in + length - radius
    cy = y_in

    # Apply rotation around the connection point (x_ini, y_ini)
    trans = transforms.Affine2D().rotate_deg_around(x_in, y_in, angle) + self.ax.transData  

    # Circle
    circle = plt.Circle((cx, cy), radius, edgecolor='black', 
                        facecolor='white', transform=trans, zorder=2)
    self.ax.add_patch(circle)

    # Draw symbol inside circle depending on operation
    rel_size = 0.7
    if operation == 'mult':
        # "X" inside circle
        dx = radius * rel_size * np.cos(np.pi / 4)  # Escalamos un poco para que quepa dentro del círculo
        dy = radius * rel_size * np.sin(np.pi / 4)
        #  45° line
        self.ax.plot([cx - dx, cx + dx], [cy - dy, cy + dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
        # 135° line
        self.ax.plot([cx - dx, cx + dx], [cy + dy, cy - dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
    elif operation == 'sum':
        dx = radius * rel_size
        dy = radius * rel_size
        # "+" inside circle
        self.ax.plot([cx - dx, cx + dx], [cy, cy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
        self.ax.plot([cx, cx], [cy - dy, cy + dy], color='black', 
                     linewidth=2, transform=trans, zorder=3)
    else:
        raise ValueError(f"Unknown operation: {operation}. Use 'sum' or 'mult'.")

    # Get rotation matrix
    rot_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]

    n = len(thread_input_pos)
    angles = np.linspace(5* np.pi / 8, 11 * np.pi / 8, n)

    arrow_width = 0.01
    arrow_head_width = 0.15

    # Input arrows
    for i, inp in enumerate(thread_input_pos):
        xi, yi = inp[:2]

        x_edge = cx + radius * np.cos(angles[i])
        y_edge = cy + radius * np.sin(angles[i])

        dx = x_edge - x_in
        dy = y_edge - y_in
        offset_vec = [dx, dy]

        # Rotated offset vector with respect to initial_position of element
        dx_rot, dy_rot = rot_matrix @ offset_vec
        # Rotated offset vector with respect to initial position of arrow
        dx_rot_rel = dx_rot - xi + x_in
        dy_rot_rel = dy_rot - yi + y_in

        self.ax.add_patch(FancyArrow(
            xi, yi, dx_rot_rel, dy_rot_rel,
            width=arrow_width,
            length_includes_head=True,
            head_width=arrow_head_width,
            color='black', transform=self.ax.transData, zorder=1
        ))

    # Compute rotated output point
    output_pos = self.__get_rotated_pos__(initial_position, [length, 0], angle)
    # Compute feedback point
    feedback_pos = self.__get_rotated_pos__(initial_position, [length - radius, -radius], angle)
    # Add element position to the dictionary
    self.__add_element_position__(input_pos=[x_in,y_in], output_pos=output_pos,
                                  feedback_pos=feedback_pos)
    return output_pos

__draw_rotated_text__(anchor_point, text, angle, rotate_text=True, ha='center', va='center', fontsize=16, offset=(0, 0))

Inner method. Draws text rotated around the anchor point with optional offset. Text position: rotation(anchor_point + offset)

Parameters:

Name Type Description Default
anchor_point NDArray or list

Coordinates of the anchor point.

required
text string

String to display. LaTeX math accepted (without $...$).

required
angle float

Rotation angle in degrees.

required
rotate_text bool

Indicates if text must be rotated or not.

True
ha string

Horizontal alignment: {'center', 'left', 'right'}.

'center'
va string

Vertical alignment: {'center', 'bottom', 'top'}.

'center'
fontsize int

Font size.

16
offset NDArray or list

Coordinates of texr position respect to anchor point, before rotation.

(0, 0)
Source code in signalblocks\DiagramBuilder.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def __draw_rotated_text__(self, anchor_point, text, angle, rotate_text = True,
                  ha='center', va='center', fontsize=16, offset=(0, 0)):
    """
    Inner method.
    Draws text rotated around the anchor point with optional offset. 
    Text position: rotation(anchor_point + offset)

    Args:
        anchor_point (Numpy.NDArray or list): Coordinates of the anchor point.
        text (string): String to display. LaTeX math accepted (without $...$).
        angle (float): Rotation angle in degrees.
        rotate_text (bool, optional): Indicates if text must be rotated or not.
        ha (string, optional): Horizontal alignment: {'center', 'left', 'right'}.
        va (string, optional): Vertical alignment: {'center', 'bottom', 'top'}.
        fontsize (int, optional): Font size.
        offset (Numpy.NDArray or list): Coordinates of texr position respect to anchor point, before rotation.
    """
    # Apply rotation to the offset vector
    dx, dy = offset
    offset_vec = np.array([dx, dy])
    rot_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]
    dx_rot, dy_rot = rot_matrix @ offset_vec

    # Compute final position
    tx = anchor_point[0] + dx_rot
    ty = anchor_point[1] + dy_rot

    if rotate_text is False:
        text_angle = 0
    else:
        text_angle = angle

    # Draw text with angle, rotating around anchor point
    self.ax.text(tx, ty, f"${text}$", ha=ha, va=va, fontsize=fontsize,
            rotation=text_angle, rotation_mode='anchor', transform=self.ax.transData)

__get_rotated_pos__(init_pos, outvector, angle)

Inner method. Compute rotated point init_pos + outvector.

Parameters:

Name Type Description Default
init_pos NDArray or list

Initial position of the block (relative origin of coordinates).

required
outvector NDArray or list

Output vector before rotation (relative position with respect to init_pos).

required
angle float

Rotation angle in degrees.

required

Returns:

Type Description
NDArray

Rotated position of vector init_pos + outvector.

Source code in signalblocks\DiagramBuilder.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def __get_rotated_pos__(self, init_pos, outvector, angle):
    """
    Inner method.
    Compute rotated point init_pos + outvector.

    Args:
        init_pos (Numpy.NDArray or list): Initial position of the block (relative origin of coordinates).
        outvector (Numpy.NDArray or list): Output vector before rotation (relative position with respect to init_pos).
        angle (float): Rotation angle in degrees.

    Returns:
        (Numpy.NDArray): Rotated position of vector init_pos + outvector.
    """

    # Output point respect to input point (before rotation)
    out_vector = np.array(outvector)
    # Rotation matrix (without translation)
    rotation_matrix = transforms.Affine2D().rotate_deg(angle).get_matrix()[:2, :2]
    # Apply rotation to the output vector
    dx, dy = rotation_matrix @ out_vector
    # Add the rotated output vector to the initial position
    return np.array([init_pos[0] + dx, init_pos[1] + dy])

__init__(block_length=1.0, block_height=1.0, fontsize=20)

(Private) Creator of the DiagramBuilder class.

Source code in signalblocks\DiagramBuilder.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def __init__(self, block_length=1.0, block_height=1.0, fontsize=20):
    """
    (Private) Creator of the DiagramBuilder class.
    """
    self.fig, self.ax = plt.subplots()
    self.ax.axis('off')  # Hide axes
    self.fontsize = fontsize
    self.block_length = block_length
    self.block_height = block_height
    self.thread_positions = {}
    self.thread_positions['main'] = [0, 0]
    # Dictionary to store element positions: input_pos, output_pos, feedback_pos
    self.element_positions = {}
    # Counter for current element
    self.current_element = -1

add(name, kind='block', thread='main', position=None, debug=False, **kwargs)

Adds an element to the block diagram at the current or specified position of a given thread.

This is the main interface for constructing diagrams by adding components such as blocks, arrows, inputs, outputs, combiners, and connectors. The kind parameter determines the type of element, and each type accepts specific keyword arguments listed below.

Parameters:

Name Type Description Default
name str

Main label or identifier for the element.

required
kind str

Type of element. One of: - 'block': Rectangular block. - 'arrow': Straight line with ending arrow. - 'angled_arrow': Rect angle line with or without ending arrow. - 'input': Arrow with text before it. - 'output': Arrow with text after it. - 'line': Straight line without arrow ending. - 'combiner': Circle with (x), (+) or (-) and additional input. - 'mult_combiner': Combiner with multiple inputs.

'block'
thread str

Thread identifier.

'main'
position tuple or str or None

(x, y) position, 'auto' (for mult_combiner), or None to use current thread position.

None
debug bool

If True, prints thread positions after placing the element.

False

The **kwargs vary depending on the kind:

  • kind = 'block':

    • text (str, optional): Label inside the block (defaults to name).
    • text_above (str, optional): Text above the block.
    • text_below (str, optional): Text below the block.
    • text_offset (float, optional): Offset for above/below text (defaults to 0.1).
    • input_text (str, optional): Label for input arrow.
    • input_side (str, optional): Side of a second optional input: {'top', 'bottom'} (defaults to None)
    • length (float, optional): Block length (defaults to self.block_length).
    • height (float, optional): Block height (defaults to self.block_height)..
    • linestyle (str, optional): Block border line style (defaults to -).
    • orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
    • fontsize (int, optional): Text font size (defaults to self.fontsize).
  • kind = 'arrow', 'input', 'output' or 'line':

    • text (str, optional): Text on the arrow or line (defaults to name).
    • text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
    • text_offset (float, optional): Offset for text (defaults to 0.1).
    • length (float, optional): Arrow or line length (defaults to self.block_length).
    • orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
    • fontsize (int, optional): Text font size (defaults to self.fontsize).
  • kind = 'angled_arrow':

    • text (str, optional): Text on the arrow or line (defaults to name).
    • final_pos (Numpy.NDarray or list): Coordinates of the ending point of the arrow.
    • text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
    • text_offset (float, optional): Offset for text (defaults to 0.1).
    • arrow (bool, optional): Indicates if it must finish or not in an arrow.
    • first_segment (string, optional): Drawing order: {'horizontal', 'vertical'}
    • orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
    • fontsize (int, optional): Text font size (defaults to self.fontsize).
  • kind = 'combiner':

    • operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaultds to 'mult').
    • height (float, optional): Vertical height of the block. (defaults to self.block_height).
    • input_side (string, optional): Side of the lateral input: {'bottom', 'top'} (defaults to 'bottom').
    • input_text (string, optional): Label for the input arrow (below or above the arrow).
    • text_offset (float, optional): Offset for text (defaults to 0.1).
    • signs (list, optional): Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.
    • orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
    • fontsize (int, optional): Text font size (defaults to self.fontsize).
  • kind = 'mult_combiner':

    • operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaults to 'mult').
    • length (float, optional): Total element length (defaults to self.block_length).
    • inputs (list of str): Thread names to combine.
    • operation (string, optional): Operation of the combiner: {'mult', 'sum'} (defaults to 'sum').
    • orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
    • fontsize (int, optional): Text font size (defaults to self.fontsize).

Examples:

>>> db = DiagramBuilder()
>>> db.add("x(t)", kind="input")
>>> db.add("H(s)", kind="block")
>>> db.add("y(t)", kind="output")
Source code in signalblocks\DiagramBuilder.py
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
def add(self,name, kind='block', thread='main', position=None, debug=False, **kwargs):
    """
    Adds an element to the block diagram at the current or specified position of a given thread.

    This is the main interface for constructing diagrams by adding components such as blocks, arrows,
    inputs, outputs, combiners, and connectors. The `kind` parameter determines the type of element,
    and each type accepts specific keyword arguments listed below.

    Args:
        name (str): Main label or identifier for the element.
        kind (str, optional): Type of element. One of:
            - 'block': Rectangular block.
            - 'arrow': Straight line with ending arrow.
            - 'angled_arrow': Rect angle line with or without ending arrow.
            - 'input': Arrow with text before it.
            - 'output': Arrow with text after it.
            - 'line': Straight line without arrow ending.
            - 'combiner': Circle with (x), (+) or (-) and additional input.
            - 'mult_combiner': Combiner with multiple inputs.
        thread (str, optional): Thread identifier.
        position (tuple or str or None, optional): (x, y) position, 'auto' (for mult_combiner), or None to use current thread position.
        debug (bool, optional): If True, prints thread positions after placing the element.

    The `**kwargs` vary depending on the `kind`:

    - **kind = 'block'**:
        - text (str, optional): Label inside the block (defaults to `name`).
        - text_above (str, optional): Text above the block.
        - text_below (str, optional): Text below the block.
        - text_offset (float, optional): Offset for above/below text (defaults to 0.1).
        - input_text (str, optional): Label for input arrow.
        - input_side (str, optional): Side of a second optional input: {'top', 'bottom'} (defaults to `None`)
        - length (float, optional): Block length (defaults to `self.block_length`).
        - height (float, optional): Block height (defaults to `self.block_height`)..
        - linestyle (str, optional): Block border line style (defaults to `-`).
        - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
        - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

    - **kind = 'arrow'**, **'input'**, **'output'** or **'line'**:
        - text (str, optional): Text on the arrow or line (defaults to `name`).
        - text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
        - text_offset (float, optional): Offset for text (defaults to 0.1).
        - length (float, optional): Arrow or line length (defaults to `self.block_length`).
        - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
        - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

    - **kind = 'angled_arrow'**:
        - text (str, optional): Text on the arrow or line (defaults to `name`).
        - final_pos (Numpy.NDarray or list): Coordinates of the ending point of the arrow.
        - text_position (str, optional): 'above', 'below', 'before', or 'after' (defaults to 'above' for 'arrow' and 'line', 'before' for 'input', and 'after' for 'output').
        - text_offset (float, optional): Offset for text (defaults to 0.1).
        - arrow (bool, optional): Indicates if it must finish or not in an arrow.
        - first_segment (string, optional): Drawing order: {'horizontal', 'vertical'}
        - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
        - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

    - **kind = 'combiner'**:
        - operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaultds to 'mult').
        - height (float, optional): Vertical height of the block. (defaults to `self.block_height`).
        - input_side (string, optional): Side of the lateral input: {'bottom', 'top'} (defaults to 'bottom').
        - input_text (string, optional): Label for the input arrow (below or above the arrow).
        - text_offset (float, optional): Offset for text (defaults to 0.1).
        - signs (list, optional): Sign to be shown on the horizontal (signs[0]) and vertical (signs[1]) inputs.
        - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
        - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

    - **kind = 'mult_combiner'**:
        - operation (string, optional): Operation of the combiner: {'mult', 'sum', 'dif'} (defaults to 'mult').
        - length (float, optional): Total element length (defaults to `self.block_length`).
        - inputs (list of str): Thread names to combine.
        - operation (string, optional): Operation of the combiner: {'mult', 'sum'} (defaults to 'sum').
        - orientation (str or float, optional): Orientation of the block: {'horizontal', 'vertical', 'up', 'down' 'right', left' or angle in degrees} (defaults to 'horizontal').
        - fontsize (int, optional): Text font size (defaults to `self.fontsize`).

    Examples:
        >>> db = DiagramBuilder()
        >>> db.add("x(t)", kind="input")
        >>> db.add("H(s)", kind="block")
        >>> db.add("y(t)", kind="output")
    """

    # If position is 'auto' (draw_mult_combiner), position is calculated inside that method
    if isinstance(position, str) and position == 'auto':
        initial_pos = 'auto'
    # If input argument position is given and not 'auto', element position is asigned to position argument value
    elif position is not None:
        initial_pos = list(position)
    # If not given
    else:
        # If thread already exists, element position is asigned from thread head
        if thread in self.thread_positions:
            initial_pos = self.thread_positions[thread]
        # If doesn't exist
        else:
            initial_pos = [0, 0]

    if kind == 'arrow':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_position': 'above',
            'arrow': True,
            'text_offset': 0.1,
            'length': self.block_length,
            'fontsize': self.fontsize,
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_arrow__(initial_pos, **block_args)

    elif kind == 'angled_arrow':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_offset': 0.1,
            'fontsize': self.fontsize,
            'orientation': 'horizontal',
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_angled_arrow__(initial_pos, **block_args)

    elif kind == 'input':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_position': 'before',
            'arrow': True,
            'text_offset': 0.1,
            'length': self.block_length,
            'fontsize': self.fontsize,
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_arrow__(initial_pos, **block_args)

    elif kind == 'output':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_position': 'after',
            'arrow': True,
            'text_offset': 0.1,
            'length': self.block_length,
            'fontsize': self.fontsize,
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_arrow__(initial_pos, **block_args)

    elif kind == 'line':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_position': 'above',
            'arrow': False,
            'text_offset': 0.1,
            'length': self.block_length,
            'fontsize': self.fontsize,
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_arrow__(initial_pos, **block_args)

    elif kind == 'block':
        # Default arguments
        default_kwargs = {
            'text': name,
            'text_above': None,
            'text_below': None,
            'text_offset': 0.1,
            'input_text': None,
            'input_side': None,
            'length': self.block_length,
            'height': self.block_height,
            'fontsize': self.fontsize,
            'linestyle': '-',
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_block__(initial_pos, **block_args)

    elif kind == 'combiner':
        # Default arguments
        default_kwargs = {
            'height': self.block_height,
            'fontsize': self.fontsize,
            'operation': 'mult',
            'input_side': 'bottom',
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_combiner__(initial_pos, **block_args)

    elif kind == 'mult_combiner':
        # Default arguments
        default_kwargs = {
            'length': self.block_length,
            'operation': 'mult',
            'orientation': 'horizontal'
        }
        # Overrides default arguments with provided ones
        block_args = {**default_kwargs, **kwargs}
        # Function call
        final_pos = self.__draw_mult_combiner__(initial_pos, **block_args)


    elif kind == 'output':
        length=kwargs.get('length', self.block_length)
        final_pos = self.__draw_io_arrow__(initial_pos, length=length, text=kwargs.get('text', name),
                      io='output', fontsize=self.fontsize)

    else:
        raise ValueError(f"Unknown block type: {kind}")

    # Update head position of thread
    self.thread_positions[thread] = final_pos

    if debug:
        self.__print_threads__()

get_current_element()

Returns the current element index (last added).

Returns:

Type Description
int

Index of the last added element.

Source code in signalblocks\DiagramBuilder.py
1045
1046
1047
1048
1049
1050
1051
1052
def get_current_element(self):
    """
    Returns the current element index (last added).

    Returns:
        (int): Index of the last added element.
    """
    return self.current_element

get_position(element=None)

Returns the positions of the specified element index. If no element specified, last added element is used. The return is a dictinoary with coordinates of input_pos, output_pos and feedback_pos (feedback port coordinates).

Parameters:

Name Type Description Default
element int

index of the element.

None

Returns:

Type Description
dict of Tuples

Dictionary with three 2-element tuples: input_pos, output_pos and feedback_pos.

Source code in signalblocks\DiagramBuilder.py
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
def get_position(self, element=None):
    """
    Returns the positions of the specified element index. If no element specified, last added element is used.
    The return is a dictinoary with coordinates of `input_pos`, `output_pos` and `feedback_pos` (feedback port coordinates).

    Args:
        element (int, optional): index of the element.

    Returns:
        (dict of Tuples): Dictionary with three 2-element tuples: `input_pos`, `output_pos` and `feedback_pos`.
    """
    if element is None:
        return self.element_positions[self.current_element]
    elif element <= self.current_element:
        return self.element_positions[element]
    else:
        raise ValueError(f"Element '{element}' not found.")

get_thread_position(thread='main')

Returns the current output position of the specified thread.

Parameters:

Name Type Description Default
thread str

Thread identifier.

'main'
Source code in signalblocks\DiagramBuilder.py
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
def get_thread_position(self, thread='main'):
    """
    Returns the current output position of the specified thread.

    Args:
        thread (str, optional): Thread identifier.
    """
    if thread in self.thread_positions:
        return self.thread_positions[thread]
    else:
        raise ValueError(f"Thread '{thread}' not found.")

print_threads()

Prints name of each thread in diagram and actual position.

Examples:

>>> from signalblocks import DiagramBuilder
>>> db = DiagramBuilder(block_length=1, fontsize=16)
>>> # Upper thread
>>> db.add("x_1(t)", kind="input", thread='upper', position=(0, 1))
>>> db.add("mult", kind="combiner", thread='upper', input_text="e^{-j\omega_0 t}", input_side='top', operation='mult')
>>> db.add("", kind="line", thread='upper')
>>> # Lower thread
>>> db.add("x_2(t)", kind="input", thread='lower', position=(0, -1))
>>> db.add("mult", kind="combiner", input_text="e^{j\omega_0 t}", input_side='bottom', operation='mult', thread='lower')
>>> db.add("", kind="line", thread='lower')
>>> input_threads = ['upper', 'lower']
>>> # Adder
>>> db.add("", kind="mult_combiner", inputs=input_threads, position="auto", operation='sum')
>>> # Rest of the diagram (main thread)
>>> db.add("x(t)", kind="output")
>>> db.show()
>>> db.print_threads()
Source code in signalblocks\DiagramBuilder.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def print_threads(self):
    """
    Prints name of each thread in diagram and actual position.

    Examples:
        >>> from signalblocks import DiagramBuilder
        >>> db = DiagramBuilder(block_length=1, fontsize=16)
        >>> # Upper thread
        >>> db.add("x_1(t)", kind="input", thread='upper', position=(0, 1))
        >>> db.add("mult", kind="combiner", thread='upper', input_text="e^{-j\\omega_0 t}", input_side='top', operation='mult')
        >>> db.add("", kind="line", thread='upper')
        >>> # Lower thread
        >>> db.add("x_2(t)", kind="input", thread='lower', position=(0, -1))
        >>> db.add("mult", kind="combiner", input_text="e^{j\\omega_0 t}", input_side='bottom', operation='mult', thread='lower')
        >>> db.add("", kind="line", thread='lower')
        >>> input_threads = ['upper', 'lower']
        >>> # Adder
        >>> db.add("", kind="mult_combiner", inputs=input_threads, position="auto", operation='sum')
        >>> # Rest of the diagram (main thread)
        >>> db.add("x(t)", kind="output")
        >>> db.show()
        >>> db.print_threads()
    """
    for thread in self.thread_positions:
        print(thread, ": ", self.thread_positions[thread])

show(margin=0.5, scale=1.0, savepath=None)

Displays the current diagram or saves it to a file.

Adjusts the view to fit the full diagram with an optional margin and scaling factor. If no elements have been drawn, simply displays an empty figure.

Parameters:

Name Type Description Default
margin float

Margin to add around the diagram (in data units).

0.5
scale float

Scaling factor for the figure size.

1.0
savepath str

If provided, saves the figure to the specified path (e.g., 'diagram.png' or 'diagram.pdf'). If None, the diagram is shown in an interactive window.

None
Source code in signalblocks\DiagramBuilder.py
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
def show(self, margin=0.5, scale=1.0, savepath=None):
    """
    Displays the current diagram or saves it to a file.

    Adjusts the view to fit the full diagram with an optional margin and scaling factor.
    If no elements have been drawn, simply displays an empty figure.

    Args:
        margin (float, optional): Margin to add around the diagram (in data units).
        scale (float, optional): Scaling factor for the figure size.
        savepath (str, optional): If provided, saves the figure to the specified path (e.g., 'diagram.png' or 'diagram.pdf').
                                  If None, the diagram is shown in an interactive window.

    """
    bbox = self.__get_bbox__()
    if bbox is None:
        plt.show()
        return

    x0 = bbox.x0 - margin
    x1 = bbox.x1 + margin
    y0 = bbox.y0 - margin
    y1 = bbox.y1 + margin

    width = x1 - x0
    height = y1 - y0

    fig_width = width * scale
    fig_height = height * scale
    self.fig.set_size_inches(fig_width, fig_height)

    self.ax.set_xlim(x0, x1)
    self.ax.set_ylim(y0, y1)
    self.ax.set_aspect("equal", adjustable="box")
    self.ax.set_position([0, 0, 1, 1])
    self.ax.axis("off")

    if savepath:
        self.fig.savefig(savepath, bbox_inches='tight', dpi=self.fig.dpi, transparent=False, facecolor='white')
        print(f"Saved in: {savepath}")
    else:
        plt.show()

SignalPlotter

SignalPlotter

A helper class to plot signals y a minimalistic way. It has predefined typical signals, like rect, tri, Heaviside, delta, sinc, ... It allow to do operations with signals, like time shifts and inversions, sums, products, convolutions, ...

Parameters:

Name Type Description Default
expr_str str

A string expression defining the signal, e.g. "x(t)=sin(t)*u(t)".

None
horiz_range tuple

Tuple (t_min, t_max) specifying the horizontal plotting range.

(-5, 5)
vert_range tuple

Tuple (y_min, y_max) specifying the vertical range. Auto-scaled if None.

None
period float

If provided, the signal is treated as periodic with this period.

None
num_points int

Number of points used to discretize the time axis.

1000
figsize tuple

Size of the figure in centimeters (width, height).

(8, 3)
tick_size_px int

Size of axis tick marks in pixels.

5
xticks list or auto or None

Positions of x-axis ticks. If 'auto', they are generated automatically.

'auto'
yticks list or auto or None

Same for y-axis.

'auto'
xtick_labels list of str

Labels for xticks. Must match xticks in length.

None
ytick_labels list of str

Labels for yticks. Must match yticks in length.

None
pi_mode bool

If True, x and y tick labels are shown as fractionary multiples of π if possible.

False
fraction_ticks bool

If True, tick labels are shown as rational fractions.

False
xticks_delta float

If provided, generates xticks at this interval (when xticks='auto').

None
yticks_delta float

Same for yticks.

None
save_path str

If provided, saves the plot to the given path instead of displaying.

None
show_plot bool

Whether to show the plot window (if False and save_path is given, it only saves).

True
color str

Color for the plot line and impulses.

'black'
alpha float

Transparency for background label boxes (between 0 and 1).

0.5

Examples:

>>> from signalblocks import SignalPlotter
>>> sp = SignalPlotter("x(t)=rect(t)", horiz_range=(-2, 2), pi_mode=True).plot()
>>> SignalPlotter("x(t)=delta(t/2-1) + 3*delta(t + 2)", color='blue', figsize=(8,4)).plot('x')
>>> signal1 = SignalPlotter("x(t)=cos(4 pi t)*tri(t/2)", alpha=0.7, horiz_range=[-3, 3], xticks=np.linspace(-2, 2, 9), color='blue', figsize=(12,4))
>>> signal1.plot()
>>> SignalPlotter("x(t)=pw((t**2, (t>-1) & (t<0)), (-t, (t>=0) & (t<1)), (0, True))", horiz_range=[-2.5, 2.5], xticks=np.linspace(-2, 2, 9), color='blue', period=2)
Source code in signalblocks\SignalPlotter.py
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
class SignalPlotter:
    """
    A helper class to plot signals y a minimalistic way. It has predefined
    typical signals, like rect, tri, Heaviside, delta, sinc, ...
    It allow to do operations with signals, like time shifts and inversions,
    sums, products, convolutions, ...

    Args:
        expr_str (str, optional): A string expression defining the signal, e.g. "x(t)=sin(t)*u(t)".
        horiz_range (tuple, optional): Tuple (t_min, t_max) specifying the horizontal plotting range.
        vert_range (tuple, optional): Tuple (y_min, y_max) specifying the vertical range. Auto-scaled if None.
        period (float, optional): If provided, the signal is treated as periodic with this period.
        num_points (int, optional): Number of points used to discretize the time axis.
        figsize (tuple, optional): Size of the figure in centimeters (width, height).
        tick_size_px (int, optional): Size of axis tick marks in pixels.
        xticks (list or 'auto' or None): Positions of x-axis ticks. If 'auto', they are generated automatically.
        yticks (list or 'auto' or None): Same for y-axis.
        xtick_labels (list of str, optional): Labels for xticks. Must match xticks in length.
        ytick_labels (list of str, optional): Labels for yticks. Must match yticks in length.
        pi_mode (bool, optional): If True, x and y tick labels are shown as fractionary multiples of π if possible.
        fraction_ticks (bool, optional): If True, tick labels are shown as rational fractions.
        xticks_delta (float, optional): If provided, generates xticks at this interval (when xticks='auto').
        yticks_delta (float, optional): Same for yticks.
        save_path (str, optional): If provided, saves the plot to the given path instead of displaying.
        show_plot (bool, optional): Whether to show the plot window (if False and save_path is given, it only saves).
        color (str, optional): Color for the plot line and impulses.
        alpha (float, optional): Transparency for background label boxes (between 0 and 1).

    Examples:
        >>> from signalblocks import SignalPlotter
        >>> sp = SignalPlotter("x(t)=rect(t)", horiz_range=(-2, 2), pi_mode=True).plot()
        >>> SignalPlotter("x(t)=delta(t/2-1) + 3*delta(t + 2)", color='blue', figsize=(8,4)).plot('x')
        >>> signal1 = SignalPlotter("x(t)=cos(4 pi t)*tri(t/2)", alpha=0.7, horiz_range=[-3, 3], xticks=np.linspace(-2, 2, 9), color='blue', figsize=(12,4))
        >>> signal1.plot()
        >>> SignalPlotter("x(t)=pw((t**2, (t>-1) & (t<0)), (-t, (t>=0) & (t<1)), (0, True))", horiz_range=[-2.5, 2.5], xticks=np.linspace(-2, 2, 9), color='blue', period=2)
    """
    def __init__(
        self,
        expr_str=None, 
        horiz_range=(-5, 5),
        vert_range=None,
        period=None,
        num_points=1000,
        figsize=(8, 3), 
        tick_size_px=5,
        xticks='auto',
        yticks='auto',
        xtick_labels=None,
        ytick_labels=None,
        xticks_delta=None,
        yticks_delta=None,
        pi_mode=False,
        fraction_ticks=False,
        save_path=None, 
        show_plot=True,
        color='black', 
        alpha=0.5 
    ):
        """
        (Private) Creator of the SignalPlotter class.
        """
        self.signal_defs = {}
        self.var_symbols = {}
        self.current_name = None
        self.horiz_range = horiz_range
        self.vert_range = vert_range
        self.num_points = num_points
        self.figsize = figsize
        self.tick_size_px = tick_size_px
        self.color = color
        self.alpha = alpha
        self.period = period
        self.save_path = save_path
        self.show_plot = show_plot

        self.fraction_ticks = fraction_ticks

        # Preserve original tick arguments to differentiate None / [] / 'auto'
        self.init_xticks_arg = xticks
        self.init_yticks_arg = yticks

        if isinstance(xticks, (list, tuple, np.ndarray)) and len(xticks) > 0:
            self.xticks = np.array(xticks)
        else:
            self.xticks = None
        if isinstance(yticks, (list, tuple, np.ndarray)) and len(yticks) > 0:
            self.yticks = np.array(yticks)
        else:
            self.yticks = None

        self.pi_mode = pi_mode

        self.xtick_labels = xtick_labels
        self.ytick_labels = ytick_labels

        if self.xtick_labels is not None:
            if self.xticks is None:
                raise ValueError("xtick_labels provided without xticks positions")
            if len(self.xtick_labels) != len(self.xticks):
                raise ValueError("xtick_labels and xticks must have the same length")
        if self.ytick_labels is not None:
            if self.yticks is None:
                raise ValueError("ytick_labels provided without yticks positions")
            if len(self.ytick_labels) != len(self.yticks):
                raise ValueError("ytick_labels and yticks must have the same length")

        self.xticks_delta = xticks_delta
        self.yticks_delta = yticks_delta

        self.expr_str_pending = expr_str  # Expression to initialize later if plot() is called first

        self.transformations = (standard_transformations + (implicit_multiplication_application,))


    def _get_local_dict(self):
        """
        (Private) Returns a local dictionary of predefined symbols and functions used
        during symbolic parsing and evaluation of signal expressions.

        This includes:
        - Common signal processing functions such as:
            - u(t): Heaviside step (centered at 0.5)
            - rect(t), tri(t), sinc(t), ramp(t), delta(t)
        - Piecewise functions:
            - Piecewise, pw
        - Mathematical functions and constants:
            - sin, cos, exp, pi, abs, arg, re, im, conj
        - Symbols used in frequency/time analysis: t, ω, Ω, τ, λ
        - Support for complex signals: i, j, re, im, conj, abs, arg 
        - Previously defined signal names in the format "name(variable)"

        Returns:
            dict: A dictionary mapping names to SymPy expressions or functions.

        Examples:
            >>> local_dict = self._get_local_dict()
            >>> expr = parse_expr("x(t) + rect(t)", local_dict=local_dict)
        """
        d = {
            'u':            lambda t: sp.Heaviside(t, 0.5),
            'rect':         lambda t: sp.Piecewise((1, sp.And(t >= -0.5, t <= 0.5)), (0, True)),
            'tri':          lambda t: (1 - abs(t)) * sp.Heaviside(1 - abs(t), 0),   # 0 explícito en bordes de triángulo
            'ramp':         lambda t: sp.Heaviside(t, 0) * t,
            'sinc':         lambda t: sp.sin(sp.pi * t) / (sp.pi * t),
            'delta':        sp.DiracDelta,
            'DiracDelta':   sp.DiracDelta,
            'Heaviside':    lambda t: sp.Heaviside(t, 0.5),
            'pi':           sp.pi,
            'sin':          sp.sin,
            'cos':          sp.cos,
            'exp':          sp.exp,
            'Piecewise':    sp.Piecewise,
            'pw':           sp.Piecewise,
            're':           sp.re,
            'im':           sp.im,
            'conj':         sp.conjugate,
            'abs':          lambda x: np.abs(x),
            'arg':          sp.arg,
            'i':            sp.I,
            'j':            sp.I,
            't':            sp.Symbol('t'),
            'omega':        sp.Symbol('omega'),
            'Omega':        sp.Symbol('Omega'),
            'tau':          sp.Symbol('tau'),
            'lambda':       sp.Symbol('lambda'),
        }
        d.update(self.var_symbols)
        for name, expr in self.signal_defs.items():
            for var in self.var_symbols.values():
                d[f"{name}({var})"] = expr.subs(var, var)
        return d

    # def _initialize_expression(self, expr_str):
    #     m = re.match(r"^(?P<fn>[^\W\d_]+)\((?P<vr>[^)]+)\)\s*=\s*(?P<ex>.+)$", expr_str)
    #     if m:
    #         self.func_name = m.group('fn')
    #         var_name = m.group('vr')
    #         expr_body = m.group('ex')
    #     else:
    #         self.func_name = 'x'
    #         var_name = 't'
    #         expr_body = expr_str

    #     replacements = {'\\omega': 'ω', '\\tau': 'τ'}
    #     for latex_var, unicode_var in replacements.items():
    #         var_name = var_name.replace(latex_var, unicode_var)
    #         expr_body = expr_body.replace(latex_var, unicode_var)

    #     self.expr_str = expr_body
    #     self.var = sp.Symbol(var_name)
    #     self.xlabel = var_name
    #     self.ylabel = self.func_name + '(' + var_name + ')'

    #     self.local_dict = self._get_local_dict()

    #     transformations = standard_transformations + (implicit_multiplication_application,)
    #     self.expr = parse_expr(expr_body, local_dict=self.local_dict, transformations=transformations)

    #     self.expr_cont = self._remove_dirac_terms()
    #     self.impulse_locs, self.impulse_areas = self._extract_impulses()

    #     t0, t1 = self.horiz_range
    #     self.t_vals = np.linspace(t0, t1, self.num_points)
    #     if self.period is not None:
    #         T = self.period
    #         self.t_vals = ((self.t_vals + T/2) % T) - T/2
    #         self.t_vals.sort()

    #     self.func = sp.lambdify(self.var, self.expr_cont, modules=["numpy", self.local_dict])
    #     self.fig, self.ax = plt.subplots(figsize=self.figsize)
    #     self._prepare_plot()

    def add_signal(self, expr_str, label=None, period=None):
        r"""
        Adds a new signal to the internal dictionary for later plotting.
        - The expression is parsed symbolically using SymPy.
        - If other signals are referenced, their definitions are recursively substituted.
        - If `period` is set, the signal will be expanded as a sum of time-shifted versions over the full horizontal range.

        Args:
            expr_str (str): Signal definition in the form "name(var) = expression", e.g. "x(t) = rect(t) + delta(t-1)". The expression may include previously defined signals.
            label (str, optional): Custom label for the vertical axis when plotting this signal.
            period (float, optional): If provided, the signal will be treated as periodic with this period.

        Examples:
            >>> sp = SignalPlotter(horiz_range=(-1, 1), fraction_ticks=True, figsize=(12, 4))
            >>> sp.add_signal("x1(t) = tri(t)")
            >>> sp.add_signal("x2(t) = delta(t)", period=0.2)
            >>> sp.add_signal("x3(t) = x1(t) * (1 + x2(t))", label="x_3(t)")
            >>> sp.plot("x3")
            >>> sp.add_signal("x4(t) = exp((-2+j*8*pi)*t)*u(t)")
            >>> sp.add_signal("x5(t) = re(x4(t))", label="\Re\{x_4(t)\}")
            >>> sp.plot('x5')
        """
        m = re.match(r"^(?P<fn>\w+)\((?P<vr>\w+)\)\s*=\s*(?P<ex>.+)$", expr_str)

        replacements = {'\\omega': 'ω', '\\tau': 'τ'}
        for latex_var, unicode_var in replacements.items():
            expr_str = expr_str.replace(latex_var, unicode_var)
        m = re.match(r"^(?P<fn>\w+)\((?P<vr>\w+)\)\s*=\s*(?P<ex>.+)$", expr_str)

        name = m.group('fn')
        var = m.group('vr')
        body = m.group('ex')

        if var not in self.var_symbols:
            self.var_symbols[var] = sp.Symbol(var)
        var_sym = self.var_symbols[var]

        local_dict = self._get_local_dict()
        for other_name in self.signal_defs:
            local_dict[other_name] = sp.Function(other_name)

        transformations = standard_transformations + (implicit_multiplication_application,)
        parsed_expr = parse_expr(body, local_dict=local_dict, transformations=transformations)

        for other_name, other_expr in self.signal_defs.items():
            f = sp.Function(other_name)
            matches = parsed_expr.find(f)
            for call in matches:
                if isinstance(call, sp.Function):
                    arg = call.args[0]
                    replaced = other_expr.subs(var_sym, arg)
                    parsed_expr = parsed_expr.subs(call, replaced)

        self.signal_defs[name] = parsed_expr
        self.var_symbols[name] = var_sym

        if label is not None:
            if not hasattr(self, 'custom_labels'):
                self.custom_labels = {}
            self.custom_labels[name] = label

        if period is not None:
            if not hasattr(self, 'signal_periods'):
                self.signal_periods = {}
            self.signal_periods[name] = period

            # Expand signal as sum of shifts within range
            horiz_min, horiz_max = self.horiz_range
            num_periods = int(np.ceil((horiz_max - horiz_min) / period))
            k_range = range(-num_periods - 2, num_periods + 3)  # márgenes extra

            # Expanded as sum of shifted expressions (in SymPy)
            expanded_expr = sum(parsed_expr.subs(var_sym, var_sym - period * k) for k in k_range)

            self.signal_defs[name] = expanded_expr
        else:
            self.signal_defs[name] = parsed_expr


    def _prepare_plot(self):
        """
        (Private) Determines the vertical plotting range (y-axis limits) based on the signal values. 
        Called from `plot()`.

        This method:
        - Evaluates the continuous part of the signal over `self.t_vals`.
        - Identifies Dirac delta impulses located within the horizontal plotting range.
        - Computes the minimum and maximum of both continuous and impulsive parts.
        - Ensures a minimal vertical span to avoid flat-looking plots.
        - Uses `self.vert_range` if explicitly provided.

        Sets:
        - self.y_min, self.y_max: Vertical limits for plotting.

        Notes:
        - If evaluation fails (e.g. undefined expression), defaults to [-1, 1].
        - Delta impulses are included only if they fall within the horizontal range.

        Examples:
            >>> self._prepare_plot()
            >>> print(self.y_min, self.y_max)  # → computed bounds
        """
        try:
            # Evaluate continuous expression
            y_vals = self.func(self.t_vals)
            y_vals = np.array(y_vals, dtype=np.float64)
            y_vals = y_vals[np.isfinite(y_vals)]

            if y_vals.size > 0:
                cont_min = np.min(y_vals)
                cont_max = np.max(y_vals)
            else:
                cont_min = 0.0
                cont_max = 0.0

            # Visible deltas in hoirzontal range
            if self.impulse_locs and self.impulse_areas:
                t_min, t_max = self.horiz_range
                filtered_areas = [
                    area for loc, area in zip(self.impulse_locs, self.impulse_areas)
                    if t_min <= loc <= t_max
                ]
                if filtered_areas:
                    imp_min = min(filtered_areas)
                    imp_max = max(filtered_areas)
                    overall_min = min(cont_min, imp_min, 0.0)
                    overall_max = max(cont_max, imp_max, 0.0)
                else:
                    overall_min = min(cont_min, 0.0)
                    overall_max = max(cont_max, 0.0)
            else:
                overall_min = min(cont_min, 0.0)
                overall_max = max(cont_max, 0.0)

            # Fit in case range is too narrow
            if abs(overall_max - overall_min) < 1e-2:
                overall_min -= 1.0
                overall_max += 1.0

            # Apply vertical range if provided
            if self.vert_range:
                self.y_min, self.y_max = self.vert_range
            else:
                self.y_min, self.y_max = overall_min, overall_max

        except Exception:
            self.y_min, self.y_max = -1, 1


    def _eval_func_array(self, t):
        """
        (Private) Evaluates the continuous (non-impulsive) part of the signal at the given time values.
        Notes:
        - This method assumes `self.func` is a callable created with `lambdify(...)`.
        - Ensures consistent array output for plotting, regardless of scalar/vector behavior.

        Args:
            t (array-like or scalar): Time values at which to evaluate the signal function.

        Returns:
            (Numpy.NDArray): A NumPy array of evaluated values with the same shape as `t`.
            If the result is scalar (i.e., constant function), it is broadcast across all `t`.

        Examples:
            >>> t_vals = np.linspace(-1, 1, 100)
            >>> y_vals = self._eval_func_array(t_vals)
        """
        y = self.func(t)
        return np.full_like(t, y, dtype=float) if np.isscalar(y) else np.array(y, dtype=float)

    def _extract_impulses(self):
        """
        (Private) Extracts the locations and amplitudes of Dirac delta impulses from the signal expression.

        This method:
        - Expands the symbolic expression `self.expr` into additive terms.
        - Identifies all DiracDelta terms and their arguments.
        - Solves each delta argument for its root(s) to determine impulse location(s).
        - Computes the effective amplitude of each impulse, accounting for time scaling:
            For δ(a·t + b), amplitude is scaled by 1/|a|.
        - Merges nearby impulses numerically (within a tolerance of 1e-8) to avoid duplicates.
        - Ignores impulses with near-zero amplitude (threshold: 1e-6).

        Returns:
            impulse_locs (list of float): Time positions of Dirac delta impulses.
            impulse_areas (list of float): Corresponding amplitudes (areas) of each impulse.

        Example:
            >>> self.expr = sp.DiracDelta(t - 1) + 2*sp.DiracDelta(2*t)
            >>> locs, areas = self._extract_impulses()
            >>> print(locs)   # → [1.0, 0.0]
            >>> print(areas)  # → [1.0, 1.0]  (2*δ(2t) has area 1 due to 1/|2| scaling)
        """
        impulse_map = {}

        # Expandir y descomponer en términos
        expr_terms = self.expr.expand().as_ordered_terms()

        for term in expr_terms:
            deltas = term.atoms(sp.DiracDelta)
            for delta in deltas:
                arg = delta.args[0]
                roots = sp.solve(arg, self.var)
                amp = term.coeff(delta)
                d_arg = sp.diff(arg, self.var)
                scale = sp.Abs(d_arg)

                for r in roots:
                    try:
                        scale_val = float(scale.subs(self.var, r))
                        amp_eval = amp.subs(self.var, r).doit().evalf()
                        effective_amp = float(amp_eval) / scale_val if scale_val != 0 else 0.0
                        if abs(effective_amp) > 1e-6:
                            loc = float(r)
                            # Buscar ubicación cercana ya existente (tolerancia)
                            found = False
                            for known_loc in impulse_map:
                                if abs(known_loc - loc) < 1e-8:
                                    impulse_map[known_loc] += effective_amp
                                    found = True
                                    break
                            if not found:
                                impulse_map[loc] = effective_amp
                    except (TypeError, ValueError, ZeroDivisionError):
                        continue

        # Filtrar deltas resultantes ≠ 0 tras sumar contribuciones
        impulse_locs = []
        impulse_areas = []
        for loc, area in impulse_map.items():
            if abs(area) > 1e-6:
                impulse_locs.append(loc)
                impulse_areas.append(area)

        return impulse_locs, impulse_areas



    def _remove_dirac_terms(self):
        """
        Removes all Dirac delta (impulse) terms from the symbolic expression.

        This method:
        - Scans `self.expr` for any subexpressions containing `DiracDelta(...)`.
        - Replaces each occurrence with 0, effectively isolating the continuous part
        of the signal (excluding impulses).

        Returns:
            sympy.Expr: A new expression identical to `self.expr` but with all DiracDelta terms removed.

        Example:
            >>> self.expr = delta(t) + sp.sin(t)
            >>> self._remove_dirac_terms()
            sin(t)
        """
        return self.expr.replace(lambda expr: expr.has(sp.DiracDelta), lambda _: 0)


    def draw_function(self, horiz_range=None):
        """
        Plots the continuous part of the signal over the specified horizontal range.
        This method is typically called after `setup_axes()`.
        This method is usually called internally from `plot()`, but can also be used manually.

        This method:
        - Evaluates the function defined in `self.func` across `self.t_vals`.
        - Plots the result as a smooth curve using the configured color and linewidth.
        - Automatically detects and adds ellipsis ("⋯") on the left/right ends if:
            - The signal is marked as periodic, or
            - Significant energy exists just outside the plotting range.

        Notes:
        - This method does not draw delta impulses. Use `draw_impulses()` for that.
        - Ellipsis are drawn at 5% beyond the plot edges when appropriate.

        Args:
            horiz_range (tuple, optional): Tuple (t_min, t_max) to override the default horizontal range. If None, uses `self.horiz_range`.

        Examples:
            >>> self.draw_function()
            >>> self.draw_impulses()  # to add deltas on top of the curve
        """

        if horiz_range is None:
            horiz_range = self.horiz_range

        t0, t1 = horiz_range
        t_plot = self.t_vals
        y_plot = self._eval_func_array(t_plot)

        # Assure arrays and format
        t_plot = np.array(t_plot)
        y_plot = np.array(y_plot)
        if y_plot.ndim == 0:
            y_plot = np.full_like(t_plot, y_plot, dtype=float)

        # Plot curve
        self.ax.plot(t_plot, y_plot, color=self.color, linewidth=2.5, zorder=5)

        # Decide whether to draw ellipsis
        delta = (t1 - t0) * 0.05
        tol = 1e-3
        span = t1 - t0
        draw_left = draw_right = False

        # Show alwais if periodic
        if hasattr(self, 'signal_periods') and self.current_name in self.signal_periods:
            draw_left = draw_right = True
        else:
            N = max(10, int(0.05 * self.num_points))
            xs_left = np.linspace(t0 - 0.05 * span, t0, N)
            ys_left = np.abs(self._eval_func_array(xs_left))
            if np.trapz(ys_left, xs_left) > tol:
                draw_left = True

            xs_right = np.linspace(t1, t1 + 0.05 * span, N)
            ys_right = np.abs(self._eval_func_array(xs_right))
            if np.trapz(ys_right, xs_right) > tol:
                draw_right = True

        # Draw ellipsis if needed
        y_mid = (self.y_min + 2 * self.y_max) / 3
        if draw_left:
            self.ax.text(t0 - delta, y_mid, r'$\cdots$', ha='left', va='center',
                        color=self.color, fontsize=14, zorder=10)
        if draw_right:
            self.ax.text(t1 + delta, y_mid, r'$\cdots$', ha='right', va='center',
                        color=self.color, fontsize=14, zorder=10)


    def draw_impulses(self):
        """
        Draws Dirac delta impulses at the extracted positions and amplitudes.
        This method is typically called after `draw_functions()`.
        This method is usually called internally from `plot()`, but can also be used manually.

        This method:
        - Iterates over the list of impulse locations (`self.impulse_locs`)
        and their corresponding amplitudes (`self.impulse_areas`).
        - Calls `_draw_single_impulse()` for each impulse located within
        the current horizontal plotting range (`self.horiz_range`).

        Notes:
        - This method only draws impulses that are visible within the plotting window.
        - Periodicity is not assumed. Use `add_signal(..., period=...)` to manually expand periodic impulses.
        - The drawing includes both a vertical arrow and a bold label showing the impulse area.

        Examples:
            >>> self.draw_function()
            >>> self.draw_impulses()
        """
        t_min, t_max = self.horiz_range
        for t0, amp in zip(self.impulse_locs, self.impulse_areas):
            if t_min <= t0 <= t_max:
                self._draw_single_impulse(t0, amp)

    def _draw_single_impulse(self, t0, amp):
        """
        Draws a single Dirac delta impulse at the specified location and amplitude.

        This method:
        - Draws a vertical arrow starting from (t0, 0) up to (t0, amp).
        - Places a bold numerical label near the tip of the arrow indicating the amplitude.
        - Slightly offsets the label horizontally if the impulse is located at or near t = 0,
        to avoid overlapping with the vertical axis.

        Notes:
        - Arrow and label use the color specified in `self.color`.
        - Label placement is adjusted to avoid axis clutter at t = 0.

        Args:
            t0 (float): The location of the impulse along the time axis.
            amp (float): The area of the impulse. Determines arrow height and label.

        Examples:
            >>> self._draw_single_impulse(1.0, 2.5)  # draws 2.5·δ(t − 1)
        """
        # Arrow from (t0,0) to (t0, amp)
        self.ax.annotate(
            '', xy=(t0, amp + 0.01 * (self.y_max - self.y_min)), xytext=(t0, 0),
            arrowprops=dict(
                arrowstyle='-|>',
                linewidth=2.5,
                color=self.color,
                mutation_scale=16
            ),
            zorder=10
        )

        # Calculate horizontal offset for the label if t0 ≈ 0
        x_min, x_max = self.ax.get_xlim()
        x_range = x_max - x_min
        # Threshold to consider that t0 is 'almost' zero
        tol = 1e-6 * max(1.0, abs(x_range))
        if abs(t0) < tol:
            # Shift label a 2% of horizontal range to the left
            x_offset = -0.01 * x_range
            ha = 'right'
        else:
            x_offset = 0.0
            ha = 'center'

        # Algin label above continuous curve if necessary
        arrow_headroom = 0.05 * (self.y_max - self.y_min)
        x_text = t0 + x_offset
        y_text = amp + arrow_headroom

        self.ax.text(
            x_text, y_text,
            f'{amp:g}',
            ha=ha,
            va='bottom' if amp > 0 else 'top',
            fontsize=12,
            color=self.color,
            fontweight='bold',
            zorder=10
        )


    def setup_axes(self, horiz_range=None):
        """
        Configures the plot axes: hides borders, sets limits, and draws arrow-like axes.
        This method is typically called after `_prepare_plot()` to finalize the plot appearance.
        This method is usually called internally from `plot()`, but can also be used manually.

        This method:
        - Hides the default box (spines) around the plot.
        - Clears all default ticks.
        - Sets the horizontal and vertical limits based on the signal range.
        - Adds margin space around the plotted data to improve visual clarity.
        - Draws custom x- and y-axis arrows using `annotate`.
        - Calls `tight_layout()` to prevent label clipping.

        Notes:
        - The horizontal axis includes a 20% margin on both sides.
        - The vertical axis includes 30% below and 60% above the data range.
        - The vertical range must be computed beforehand via `_prepare_plot()`.

        Args:
            horiz_range (tuple, optional): If provided, overrides the default horizontal range.

        Examples:
            >>> self._prepare_plot()
            >>> self.setup_axes()
        """
        # Hide all axis spines (borders)
        for spine in self.ax.spines.values():
            spine.set_color('none')

        # Remove default ticks
        self.ax.set_xticks([])
        self.ax.set_yticks([])

        if horiz_range is None:
            horiz_range = self.horiz_range

        # Compute horizontal range and margin
        x0, x1 = horiz_range
        x_range = x1 - x0
        # You can adjust this value if needed
        x_margin = 0.2 * x_range

        # Use vertical range computed in _prepare_plot
        y_min, y_max = self.y_min, self.y_max
        y_range = y_max - y_min

        # Add a 30% margin below and 60% margin above the signal range
        if y_range <= 0:
            # In degenerate cases, ensure a minimum visible height
            y_margin = 1.0
        else:
            y_margin = 0.3 * y_range

        self.ax.set_xlim(horiz_range[0] - x_margin, horiz_range[1] + x_margin)
        self.ax.set_ylim(self.y_min - y_margin, self.y_max + 1.6 * y_margin)

        # Draw x-axis arrow
        self.ax.annotate('', xy=(self.ax.get_xlim()[1], 0), xytext=(self.ax.get_xlim()[0], 0),
                         arrowprops=dict(arrowstyle='-|>', linewidth=1.5, color='black',
                                         mutation_scale=16, mutation_aspect=0.8, fc='black'))

        # Draw x-axis arrow
        self.ax.annotate('', xy=(0, self.ax.get_ylim()[1]), xytext=(0, self.ax.get_ylim()[0]),
                         arrowprops=dict(arrowstyle='-|>', linewidth=1.5, color='black',
                                         mutation_scale=12, mutation_aspect=2, fc='black'))

        # Prevent labels from being clipped
        self.fig.tight_layout()

    def draw_ticks(self,
                tick_size_px=None,
                xticks=None,
                yticks=None,
                xtick_labels='auto',
                ytick_labels='auto'):
        """
        Draws tick marks and labels on both x- and y-axes, using automatic or manual configurations.
        This method is typically called after `draw_impulses()`.
        This method is usually called internally from `plot()`, but can also be used manually.

        Features:
        - Adds tick marks and LaTeX-formatted labels.
        - Integrates impulse positions into xticks automatically, unless explicitly overridden.
        - Supports:
            - `pi_mode`: labels as multiples of π.
            - `fraction_ticks`: labels as rational fractions.
            - Hiding y=0 label if x=0 tick is shown (to avoid overlapping at the origin).
        - Avoids duplicate tick values based on numerical tolerance.

        Notes:
        - If `xticks_delta` or `yticks_delta` are provided (in constructor), evenly spaced ticks are placed at multiples.
        - If custom labels are passed and their count does not match the tick list, raises ValueError.
        - Labels are drawn on white rounded boxes for visibility over plots.

        Args:
            tick_size_px (int, optional): Length of tick marks in pixels. If None, uses self.tick_size_px.
            xticks (list or 'auto' or None): X-axis tick positions.
                - 'auto': automatically computed ticks (even spacing or using xticks_delta).
                - list: manually specified tick positions.
                - None: ticks are shown only at Dirac impulse positions.
            yticks (list or 'auto' or None): Y-axis tick positions.
                - Same behavior as `xticks`.
            xtick_labels (list or 'auto' or None): Custom labels for xticks (must match length of xticks).
            ytick_labels (list or 'auto' or None): Same for yticks.

        Examples:
            >>> self.draw_ticks(xticks='auto', yticks=[-1, 0, 1], ytick_labels=['-1', '0', '1'])
        """

        # Helper: filter duplicate values with tolerance
        def unique_sorted(values, tol):
            unique = []
            for v in values:
                if not any(abs(v - u) <= tol for u in unique):
                    unique.append(v)
            return sorted(unique)

        # Helper: get impulse locations and amplitudes within range (with periodic extension if needed)
        def get_impulse_positions_and_areas(t_min, t_max, tol):
            impulse_positions = []
            impulse_positions_areas = []
            if self.impulse_locs:
                if self.period is None:
                    # Non-periodic case: keep impulses in visible range
                    for base_loc, base_area in zip(self.impulse_locs, self.impulse_areas):
                        if t_min - tol <= base_loc <= t_max + tol:
                            impulse_positions.append(base_loc)
                            impulse_positions_areas.append(base_area)
                else:
                    # Periodic case: replicate impulses across periods
                    T = self.period
                    for base_loc, base_area in zip(self.impulse_locs, self.impulse_areas):
                        k_min = int(np.floor((t_min - base_loc) / T))
                        k_max = int(np.ceil((t_max - base_loc) / T))
                        for k in range(k_min, k_max + 1):
                            t_k = base_loc + k * T
                            if t_min - tol <= t_k <= t_max + tol:
                                impulse_positions.append(t_k)
                                impulse_positions_areas.append(base_area)
            # Eliminate duplicates within tolerance
            unique_pos = []
            unique_area = []
            for loc, area in zip(impulse_positions, impulse_positions_areas):
                if not any(abs(loc - u) <= tol for u in unique_pos):
                    unique_pos.append(loc)
                    unique_area.append(area)
            if unique_pos:
                idx_sort = np.argsort(unique_pos)
                impulse_positions = [unique_pos[i] for i in idx_sort]
                impulse_positions_areas = [unique_area[i] for i in idx_sort]
            else:
                impulse_positions, impulse_positions_areas = [], []
            return impulse_positions, impulse_positions_areas

        # Helper: validate tick list
        def has_valid_ticks(ticks):
            if ticks is None:
                return False
            try:
                arr = np.array(ticks)
                return arr.ndim >= 1 and arr.size >= 1
            except Exception:
                return False

        # Helper: generate xticks and labels
        def generate_xticks(effective_xticks, impulse_positions, tol, t_min, t_max):
            raw_xticks = []
            manual_xticks = []
            manual_xlabels = []

            has_init_xticks = has_valid_ticks(getattr(self, 'xticks', None))
            xticks_delta = getattr(self, 'xticks_delta', None)  # Nuevo atributo opcional

            if isinstance(effective_xticks, str) and effective_xticks == 'auto':
                if has_init_xticks:
                    raw_xticks = list(self.xticks)
                    if self.xtick_labels is not None:
                        if len(self.xticks) != len(self.xtick_labels):
                            raise ValueError("xtick_labels and xticks from init must have the same length")
                        manual_xticks = list(self.xticks)
                        manual_xlabels = list(self.xtick_labels)
                else:
                    if xticks_delta is not None:
                        n_left = int(np.floor((0 - t_min) / xticks_delta))
                        n_right = int(np.floor((t_max - 0) / xticks_delta))
                        base_ticks = [k * xticks_delta for k in range(-n_left, n_right + 1)]
                    else:
                        base_ticks = list(np.linspace(t_min, t_max, 5))
                    raw_xticks = base_ticks.copy()

                # Add impulses
                for loc in impulse_positions:
                    if t_min - tol <= loc <= t_max + tol and not any(abs(loc - x0) <= tol for x0 in raw_xticks):
                        raw_xticks.append(loc)

            else:
                if xticks_delta is not None:
                    warnings.warn("xticks_delta will be ignored because xticks not in 'auto' mode", stacklevel=2)
                raw_xticks = list(effective_xticks)
                if xtick_labels not in (None, 'auto'):
                    if len(raw_xticks) != len(xtick_labels):
                        raise ValueError("xtick_labels and xticks must have the same length")
                    manual_xticks = list(raw_xticks)
                    manual_xlabels = list(xtick_labels)
                elif self.xtick_labels is not None:
                    if len(raw_xticks) != len(self.xtick_labels):
                        raise ValueError("xtick_labels and xticks from init must have the same length")
                    manual_xticks = list(raw_xticks)
                    manual_xlabels = list(self.xtick_labels)

                for loc in impulse_positions:
                    if t_min - tol <= loc <= t_max + tol and not any(abs(loc - x0) <= tol for x0 in raw_xticks):
                        raw_xticks.append(loc)

            raw_xticks = unique_sorted(raw_xticks, tol)

            # Gnerate labels
            xlabels = []
            for x in raw_xticks:
                label = None
                for xm, lbl in zip(manual_xticks, manual_xlabels):
                    if abs(xm - x) <= tol:
                        label = lbl
                        break
                if label is None:
                    if getattr(self, 'pi_mode', False):
                        f = Fraction(x / np.pi).limit_denominator(24)
                        if abs(float(f) * np.pi - x) > tol:
                            label = f'{x:g}'
                        elif f == 0:
                            label = '0'
                        elif f == 1:
                            label = r'\pi'
                        elif f == -1:
                            label = r'-\pi'
                        else:
                            num = f.numerator
                            denom = f.denominator
                            prefix = '-' if num * denom < 0 else ''
                            num, denom = abs(num), abs(denom)
                            if denom == 1:
                                label = rf"{prefix}{num}\pi"
                            elif num == 1:
                                label = rf"{prefix}\frac{{\pi}}{{{denom}}}"
                            else:
                                label = rf"{prefix}\frac{{{num}\pi}}{{{denom}}}"
                    elif getattr(self, 'fraction_ticks', False):
                        f = Fraction(x).limit_denominator(24)
                        label = f"{f.numerator}/{f.denominator}" if f.denominator != 1 else f"{f.numerator}"
                    else:
                        label = f'{x:g}'
                xlabels.append(label)

            return raw_xticks, xlabels

        # Helper: generate yticks and labels
        def generate_yticks(effective_yticks, tol):
            raw_yticks = []
            manual_yticks = []
            manual_ylabels = []

            has_init_yticks = has_valid_ticks(getattr(self, 'yticks', None))
            ytick_labels = getattr(self, 'ytick_labels', None)
            ydelta = getattr(self, 'yticks_delta', None)

            if effective_yticks is None:
                raw_yticks = []
            elif isinstance(effective_yticks, str) and effective_yticks == 'auto':
                if has_init_yticks:
                    raw_yticks = list(self.yticks)
                    if self.ytick_labels is not None:
                        if len(self.yticks) != len(self.ytick_labels):
                            raise ValueError("ytick_labels and yticks from init must have the same length")
                        manual_yticks = list(self.yticks)
                        manual_ylabels = list(self.ytick_labels)
                    if ydelta is not None:
                        warnings.warn("yticks_delta ignored because yticks where specified at init")
                else:
                    if ydelta is not None and ydelta > 0:
                        y_start = np.ceil(self.y_min / ydelta)
                        y_end = np.floor(self.y_max / ydelta)
                        raw_yticks = [k * ydelta for k in range(int(y_start), int(y_end) + 1)]
                    else:
                        y0 = np.floor(self.y_min)
                        y1 = np.ceil(self.y_max)
                        if abs(y1 - y0) < 1e-6:
                            raw_yticks = [y0 - 1, y0, y0 + 1]
                        else:
                            raw_yticks = list(np.linspace(y0, y1, 3))
            else:
                raw_yticks = list(effective_yticks)
                if ydelta is not None:
                    warnings.warn("yticks_delta ignored because yticks is not in 'auto' mode")

                if ytick_labels not in (None, 'auto'):
                    if len(raw_yticks) != len(ytick_labels):
                        raise ValueError("ytick_labels and yticks must have the same length")
                    manual_yticks = list(raw_yticks)
                    manual_ylabels = list(ytick_labels)
                elif self.ytick_labels is not None:
                    if len(raw_yticks) != len(self.ytick_labels):
                        raise ValueError("ytick_labels and yticks from init must have the same length")
                    manual_yticks = list(raw_yticks)
                    manual_ylabels = list(self.ytick_labels)

            raw_yticks = unique_sorted(raw_yticks, tol)

            ylabels = []
            for y in raw_yticks:
                label = None
                for ym, lbl in zip(manual_yticks, manual_ylabels):
                    if abs(ym - y) <= tol:
                        label = lbl
                        break
                if label is None:
                    if self.pi_mode:
                        f = Fraction(y / np.pi).limit_denominator(24)
                        if abs(float(f) * np.pi - y) > tol:
                            label = f'{y:.3g}'
                        elif f == 0:
                            label = '0'
                        elif f == 1:
                            label = r'\pi'
                        elif f == -1:
                            label = r'-\pi'
                        else:
                            num = f.numerator
                            denom = f.denominator
                            prefix = '-' if num * denom < 0 else ''
                            num, denom = abs(num), abs(denom)
                            if denom == 1:
                                label = rf"{prefix}{num}\pi"
                            elif num == 1:
                                label = rf"{prefix}\frac{{\pi}}{{{denom}}}"
                            else:
                                label = rf"{prefix}\frac{{{num}\pi}}{{{denom}}}"
                    elif self.fraction_ticks:
                        f = Fraction(y).limit_denominator(24)
                        label = f"{f.numerator}/{f.denominator}" if f.denominator != 1 else f"{f.numerator}"
                    else:
                        label = f'{y:.3g}'
                ylabels.append(label)

            return raw_yticks, ylabels

        # Helper: hide y=0 label if x=0 tick exists
        def filter_yticks(raw_yticks, ylabels, raw_xticks, tol):
            has_xtick_zero = any(abs(x) <= tol for x in raw_xticks)
            if has_xtick_zero:
                filtered_yticks = []
                filtered_ylabels = []
                for y, lbl in zip(raw_yticks, ylabels):
                    if abs(y) <= tol:
                        continue
                    filtered_yticks.append(y)
                    filtered_ylabels.append(lbl)
                return filtered_yticks, filtered_ylabels
            else:
                return raw_yticks, ylabels

        # Helper: convert pixel length to data coordinates
        def px_to_data_length(tick_px):
            origin_disp = self.ax.transData.transform((0, 0))
            up_disp = origin_disp + np.array([0, tick_px])
            right_disp = origin_disp + np.array([tick_px, 0])
            origin_data = np.array(self.ax.transData.inverted().transform(origin_disp))
            up_data = np.array(self.ax.transData.inverted().transform(up_disp))
            right_data = np.array(self.ax.transData.inverted().transform(right_disp))
            dy = up_data[1] - origin_data[1]
            dx = right_data[0] - origin_data[0]
            return dx, dy

        # Helper: draw xticks and labels
        def draw_xticks(raw_xticks, xlabels, impulse_positions, impulse_positions_areas, dx, dy, tol):
            xlim = self.ax.get_xlim()
            for x, lbl in zip(raw_xticks, xlabels):
                if xlim[0] <= x <= xlim[1]:
                    self.ax.plot([x, x], [0 - dy/2, 0 + dy/2], transform=self.ax.transData,
                                color='black', linewidth=1.2, clip_on=False)
                    area = None
                    for loc, a in zip(impulse_positions, impulse_positions_areas):
                        if abs(loc - x) <= tol:
                            area = a
                            break
                    y_off = +8 if (area is not None and area < 0) else -8
                    offset = (-8, y_off) if abs(x) < tol else (0, y_off)
                    va = 'bottom' if y_off > 0 else 'top'
                    self.ax.annotate(rf'${lbl}$', xy=(x, 0), xycoords='data',
                                    textcoords='offset points', xytext=offset,
                                    ha='center', va=va, fontsize=12, zorder=10,
                                    bbox=dict(boxstyle='round,pad=0.1', facecolor='white',
                                            edgecolor='none', alpha=self.alpha))

        # Helper: draw yticks and labels
        def draw_yticks(raw_yticks, ylabels, dx, dy):
            ylim = self.ax.get_ylim()
            for y, lbl in zip(raw_yticks, ylabels):
                if ylim[0] <= y <= ylim[1]:
                    self.ax.plot([0 - dx/2, 0 + dx/2], [y, y], transform=self.ax.transData,
                                color='black', linewidth=1.2, clip_on=False)
                    offset = (-4, -16) if abs(y) < 1e-10 else (-4, 0)
                    self.ax.annotate(rf'${lbl}$', xy=(0, y), xycoords='data',
                                    textcoords='offset points', xytext=offset,
                                    ha='right', va='center', fontsize=12, zorder=10,
                                    bbox=dict(boxstyle='round,pad=0.1', facecolor='white',
                                            edgecolor='none', alpha=self.alpha))

         # === MAIN LOGIC ===

        # 0. Use constructor defaults if nothing passed explicitly
        effective_xticks = xticks if xticks is not None else getattr(self, 'init_xticks_arg', None)
        effective_yticks = yticks if yticks is not None else getattr(self, 'init_yticks_arg', None)

        # 1. Determine tick size in pixels
        tick_px = tick_size_px if tick_size_px is not None else self.tick_size_px

        # 2. Get plotting range and numeric tolerance
        t_min, t_max = self.horiz_range
        tol = 1e-8 * max(1.0, abs(t_max - t_min))

        # 3. Get impulse positions in the current range
        impulse_positions, impulse_positions_areas = get_impulse_positions_and_areas(t_min, t_max, tol)

        # 4. Generate x ticks and labels
        raw_xticks, xlabels = generate_xticks(effective_xticks, impulse_positions, tol, t_min, t_max)

        # 5. Generate y ticks and labels
        raw_yticks, ylabels = generate_yticks(effective_yticks, tol)

        # 6. Remove y=0 label if x=0 tick exists
        raw_yticks, ylabels = filter_yticks(raw_yticks, ylabels, raw_xticks, tol)

        # 7. Convert tick length in px to data coordinates
        dx, dy = px_to_data_length(tick_px)

        # 8. Draw x-axis ticks and labels
        draw_xticks(raw_xticks, xlabels, impulse_positions, impulse_positions_areas, dx, dy, tol)

        # 9. Draw y-axis ticks and labels
        draw_yticks(raw_yticks, ylabels, dx, dy)


    def draw_labels(self):
        """
        Adds axis labels to the x and y axes using the current signal variable and name.
        This method is typically called after `draw_ticks()`.
        This method is usually called internally from `plot()`, but can also be used manually.

        This method:
        - Retrieves the current axis limits.
        - Places the x-axis label slightly to the right of the horizontal axis arrow.
        - Places the y-axis label slightly below the top of the vertical axis arrow.
        - Uses LaTeX formatting for the labels (e.g., "x(t)", "y(\\tau)", etc.).
        - The labels use the values of `self.xlabel` and `self.ylabel`.

        Notes:
        - This method is called automatically in `plot()` after drawing ticks and arrows.
        - Rotation for y-axis label is disabled to keep it horizontal.

        Examples:
            >>> self.draw_labels()
        """
        # Get the current x and y axis limits
        x_lim = self.ax.get_xlim()
        y_lim = self.ax.get_ylim()

        # X-axis label: slightly to the right of the rightmost x limit
        x_pos = x_lim[1] - 0.01 * (x_lim[1] - x_lim[0])
        y_pos = 0.02 * (y_lim[1] - y_lim[0])
        self.ax.text(x_pos, y_pos, rf'${self.xlabel}$', fontsize=16, ha='right', va='bottom')

        # Y-axis label: slightly below the top y limit (still inside the figure)
        x_pos = 0.01 * (x_lim[1] - x_lim[0])
        y_pos = y_lim[1] - 0.1 * (y_lim[1] - y_lim[0])
        self.ax.text(x_pos, y_pos, rf'${self.ylabel}$', fontsize=16, ha='left', va='bottom', rotation=0)

    def _update_expression_and_func(self, expr, var):
        """
        Internal helper to update self.expr, self.expr_cont and self.func
        for plotting, safely removing any DiracDelta terms.
        """
        self.expr = expr
        self.var = var
        self.expr_cont = self._remove_dirac_terms()
        self.func = sp.lambdify(self.var, self.expr_cont, modules=["numpy", self._get_local_dict()])
        self.impulse_locs, self.impulse_areas = self._extract_impulses()

    def show(self):
        """
        Displays or saves the final plot, depending on configuration.
        This method is typically called after `draw_labels()`.
        This method is usually called internally from `plot()`, but can also be used manually.

        This method:
        - Disables the background grid.
        - Applies tight layout to reduce unnecessary whitespace.
        - If `self.save_path` is set, saves the figure to the given file (PNG, PDF, etc.).
        - If `self.show_plot` is True, opens a plot window (interactive view).
        - Finally, closes the figure to free up memory (especially important in batch plotting).

        Notes:
        - `self.save_path` and `self.show_plot` are set in the constructor.
        - If both are enabled, the plot is shown and saved.
        - The output file format is inferred from the file extension.

        Examples:
            >>> self.show()  # Typically called at the end of plot()
        """
        self.ax.grid(False)
        plt.tight_layout()
        if self.save_path:
            self.fig.savefig(self.save_path, dpi=300, bbox_inches='tight')
        if self.show_plot:
            plt.show()
        plt.close(self.fig)

    def plot(self, name=None):
        """
        Plots the signal specified by name (or the default if defined via expr_str in constructor).

        This method:
        - Initializes the expression from `expr_str_pending` (if any), only once.
        - Looks up the signal in the internal dictionary (`self.signal_defs`) using its name.
        - Sets up symbolic and numeric representations of the signal.
        - Removes DiracDelta terms from the continuous part.
        - Extracts impulses and prepares data for plotting.
        - Calls the full sequence: axis setup, function drawing, impulses, ticks, labels, and final display/save.

        Args:
            name (str, optional): Name of the signal to plot, e.g., "x1". If None and `expr_str` was given at init,
                        it uses the last-added expression.

        Raises:
            ValueError: If the signal is not defined or its variable cannot be determined.

        Examples:
            >>> SignalPlotter("x(t)=rect(t-1)").plot()
            >>> sp1 = SignalPlotter("x(t)=rect(t-1)", period=2)
            >>> sp1.plot("x")
            >>> sp2 = SignalPlotter()
            >>> sp2.add_signal("x(t) = rect(t)")
            >>> sp2.plot("x")
        """
        # Initialize from expr_str (only once), if provided at construction
        if (hasattr(self, 'expr_str_pending') and 
            self.expr_str_pending is not None and 
            isinstance(self.expr_str_pending, str) and 
            not getattr(self, '_initialized_from_expr', False)):
            expr_str = self.expr_str_pending
            self._initialized_from_expr = True
            self.add_signal(expr_str, period=self.period)
            name = list(self.signal_defs.keys())[-1]

        if name:
            if name not in self.signal_defs:
                raise ValueError(f"Signal '{name}' is not defined.")
            self.current_name = name
            self.func_name = name

            # Use declared variable or infer it
            expr = self.signal_defs[name]
            var = self.var_symbols.get(name, None)
            if var is None:
                free_vars = list(expr.free_symbols)
                if not free_vars:
                    raise ValueError(f"Could not determine the variable for signal '{name}'.")
                var = free_vars[0]

            # Update expression and lambdified function, remove Dirac terms, extract impulses
            self._update_expression_and_func(expr, var)

            # Use declared variable or infer it
            expr = self.signal_defs[name]
            var = self.var_symbols.get(name, None)
            if var is None:
                free_vars = list(expr.free_symbols)
                if not free_vars:
                    raise ValueError(f"Could not determine the variable for signal '{name}'.")
                var = free_vars[0]

            # Update expression and lambdified function, remove Dirac terms, extract impulses
            self._update_expression_and_func(expr, var)

            # Set axis labels
            self.xlabel = str(var)
            self.ylabel = f"{self.func_name}({self.xlabel})"
            if hasattr(self, 'custom_labels') and self.func_name in self.custom_labels:
                self.ylabel = self.custom_labels[self.func_name]

            # Time discretization for plotting
            self.t_vals = np.linspace(*self.horiz_range, self.num_points)

            # Create figure and compute y-range
            self.fig, self.ax = plt.subplots(figsize=self.figsize)
            self._prepare_plot()

        # Draw all components of the plot
        self.setup_axes()
        self.draw_function()
        self.draw_impulses()
        self.ax.relim()
        self.ax.autoscale_view()
        self.draw_ticks()
        self.draw_labels()
        self.show()

    # Definir las transformaciones a nivel global para uso posterior
    # def _get_transformations(self):
    #     return self.transformations

    ## Convolution-specific methods

    def _setup_figure(self):
        """
        Initializes the Matplotlib figure and axes for plotting.

        This method:
        - Creates a new figure and axis using the configured `figsize`.
        - Calls `_prepare_plot()` to compute vertical bounds for plotting based on the signal.
        - Applies padding to the layout using `subplots_adjust` to avoid clipping of labels and arrows.

        Notes:
        - This method is typically used in convolution plotting routines where a clean figure is needed.
        - For standard plotting, `plot()` uses its own setup sequence and may not rely on this method.

        Examples:
            >>> self._setup_figure()
            >>> self.draw_function()
            >>> self.show()
        """
        self.fig, self.ax = plt.subplots(figsize=self.figsize)
        self._prepare_plot()
        self.fig.subplots_adjust(right=0.9, top=0.85, bottom=0.15)


    def plot_convolution_view(self, expr_str, t_val, label=None, tau=None, t=None):
        """
        Plots an intermediate signal in the convolution process, such as x(t−τ), h(τ+t), etc.

        This method:
        - Substitutes the convolution variable t with a fixed value `t_val` in a symbolic expression.
        - Evaluates the resulting signal in terms of τ.
        - Optionally adjusts x-axis direction and labels if the expression has a form like (t − τ) or (t + τ).
        - Automatically handles periodic xtick reversal or shift based on convolution expression.
        - Renders the function using existing plot methods (function, impulses, ticks, etc.).

        Args:
            expr_str (str): A symbolic expression involving τ and t, e.g. "x(t - tau)" or "h(t + tau)".
                        The base signal must already be defined with `add_signal(...)`.
            t_val (float): The value of the time variable `t` at which the expression is evaluated.
            label (str, optional): Custom y-axis label to display (default is derived from the expression).
            tau (sympy.Symbol or str, optional): Symbol to use as integration variable (default: 'tau').
            t (sympy.Symbol or str, optional): Symbol used in shifting (default: 't').

        Raises:
            ValueError: If the base signal is not defined or the expression format is invalid.

        Examples:
            >>> sp = SignalPlotter(xticks=[-1, 0, 3], num_points=200, fraction_ticks=True)
            >>> sp.add_signal("x(t)=exp(-2t)*u(t)")
            >>> sp.add_signal("h(t)=u(t)")
            >>> sp.plot_convolution_view("x(t - tau)", t_val=1)
            >>> sp.plot_convolution_view("h(t + tau)", t_val=2, tau='lambda', t='omega')
        """
        import re
        local_dict = self._get_local_dict()

        # Define symbolic variables for τ and t
        if tau is None:
            tau = local_dict.get('tau')
        elif isinstance(tau, str):
            tau = sp.Symbol(tau)
        if t is None:
            t = local_dict.get('t')
        elif isinstance(t, str):
            t = sp.Symbol(t)

        local_dict.update({'tau': tau, 't': t, str(tau): tau, str(t): t})

        # Extract base signal name and ensure it's defined
        if "(" in expr_str:
            name = expr_str.split("(")[0].strip()
            if name not in self.signal_defs:
                raise ValueError(f"Signal '{name}' is not defined.")
            expr_base = self.signal_defs[name]
            var_base = self.var_symbols.get(name, t)
        else:
            raise ValueError("Invalid expression: expected something like 'x(t - tau)'.")

        # Parse expression and apply to base
        parsed_expr = parse_expr(expr_str.replace(name, "", 1), local_dict)
        expr = expr_base.subs(var_base, parsed_expr)

        # Analyze structure to adapt axis
        xticks = self.init_xticks_arg
        horiz_range = self.horiz_range
        xticks_custom = None
        xtick_labels_custom = None

        if isinstance(parsed_expr, sp.Expr):
            diff1 = parsed_expr - tau
            if diff1.has(t):
                diff2 = parsed_expr - t
                coef = diff2.coeff(tau)
                if coef == -1:
                    # Case t - tau ⇒ reverse x-axis
                    if xticks == 'auto':
                        xticks_custom = [t_val]
                        xtick_labels_custom = [sp.latex(t)]
                    elif isinstance(xticks, (list, tuple)):
                        xticks_custom = [t_val - v for v in xticks][::-1]
                        xtick_labels_custom = [
                            f"{sp.latex(t)}" if v == 0 else f"{sp.latex(t)}{'-' if v > 0 else '+'}{abs(v)}"
                            for v in xticks
                        ][::-1]
                    horiz_range = (t_val - np.array(self.horiz_range)[::-1]).tolist()
                elif coef == 1:
                    # Case t + tau ⇒ shift axis
                    if xticks == 'auto':
                        xticks_custom = [- t_val]
                        xtick_labels_custom = [sp.latex(t)]
                    elif isinstance(xticks, (list, tuple)):
                        xticks_custom = [- t_val + v for v in xticks]
                        xtick_labels_custom = [
                            f"-{sp.latex(t)}" if v == 0 else f"-{sp.latex(t)}{'+' if v > 0 else '-'}{abs(v)}"
                            for v in xticks
                        ]
                    horiz_range = (np.array(self.horiz_range) - t_val).tolist()

        # Evaluate the expression at t = t_val
        expr_evaluated = expr.subs(t, t_val)

        # Update expression and lambdified function
        self._update_expression_and_func(expr_evaluated, tau)

        # Axis labels
        self.xlabel = sp.latex(tau)
        tau_str = sp.latex(tau)
        t_str = sp.latex(t)
        self.ylabel = label if label else expr_str.replace("tau", tau_str).replace("t", t_str)

        # Discretize time
        self.t_vals = np.linspace(*horiz_range, self.num_points)

        # Prepare and render plot
        self._setup_figure()
        self.setup_axes(horiz_range)
        self.draw_function(horiz_range)
        self.draw_impulses()
        self.draw_ticks(xticks=xticks_custom, xtick_labels=xtick_labels_custom)
        self.draw_labels()
        self.show()



    def plot_convolution_steps(self, x_name, h_name, t_val, tau=None, t=None):
        """
        Plots four key signals involved in a convolution step at a fixed time `t_val`:
        x(tau), x(t-tau), h(tau), h(t-tau)., all in terms of τ.

        This method is particularly useful for visualizing the time-reversed and shifted
        versions of the input signals used in the convolution integral.

        Notes:
        - The horizontal axis is adjusted for time-reversed signals (e.g., t−τ),
        and tick labels are shifted accordingly.
        - Four separate plots are generated in sequence, with labels and axes automatically set.

        Args:
            x_name (str): Name of the signal x, previously defined with `add_signal(...)`.
            h_name (str): Name of the signal h, previously defined with `add_signal(...)`.
            t_val (float): The fixed time t at which the convolution step is evaluated.
            tau (sympy.Symbol or str, optional): Symbol for the integration variable (default: 'tau').
            t (sympy.Symbol or str, optional): Symbol for the time variable (default: 't').

        Examples:
            >>> sp = SignalPlotter()
            >>> sp.add_signal("x(t)=sinc(t)")
            >>> sp.add_signal("h(t)=sinc(t/2)")
            >>> sp.plot_convolution_steps("x", "h", t_val=1)
        """
        local_dict = self._get_local_dict()

        # Use default symbols if not provided
        if tau is None:
            tau = local_dict.get('tau')
        elif isinstance(tau, str):
            tau = sp.Symbol(tau)

        if t is None:
            t = local_dict.get('t')
        elif isinstance(t, str):
            t = sp.Symbol(t)

        # Evaluate x(τ) and h(τ) using their respective symbolic variable
        x_expr = self.signal_defs[x_name].subs(self.var_symbols[x_name], tau)
        h_expr = self.signal_defs[h_name].subs(self.var_symbols[h_name], tau)

        # Compute x(t−τ) and h(t−τ), and substitute t = t_val
        x_shift = x_expr.subs(tau, t - tau).subs(t, t_val)
        h_shift = h_expr.subs(tau, t - tau).subs(t, t_val)

        # Convert to LaTeX strings for labeling
        tau_str = sp.latex(tau)
        t_str = sp.latex(t)

        # Generate custom xticks and labels for the shifted signals
        xticks = self.init_xticks_arg
        if xticks == 'auto':
            xticks_shifted = [t_val]
            xtick_labels_shifted = [f"{t_str}"]
        elif isinstance(xticks, (list, tuple)):
            xticks_shifted = [t_val - v for v in xticks]
            xtick_labels_shifted = []
            for v in xticks:
                delta = - v
                if delta == 0:
                    label = fr"{t_str}"
                elif delta > 0:
                    label = fr"{t_str}+{delta}"
                else:
                    label = fr"{t_str}{delta}"  # delta is already negative
                xtick_labels_shifted.append(label)
        else:
            xticks_shifted = None
            xtick_labels_shifted = None

        horiz_range = self.horiz_range
        # Compute reversed horizontal range for time-reversed signals
        horiz_range_shifted = t_val - np.array(horiz_range)[::-1]

        # Define all 4 signals to be plotted with labels and optional custom ticks
        items = [
            (x_expr, fr"{x_name}({tau_str})", None, None, horiz_range),
            (h_expr, fr"{h_name}({tau_str})", None, None, horiz_range),
            (x_shift, fr"{x_name}({t_str}-{tau_str})", xticks_shifted, xtick_labels_shifted, horiz_range_shifted),
            (h_shift, fr"{h_name}({t_str}-{tau_str})", xticks_shifted, xtick_labels_shifted, horiz_range_shifted),
        ]

        for expr, label, xticks_custom, xtick_labels_custom, horiz_range_custom in items:
            # Prepare expression and plot configuration
            self._update_expression_and_func(expr, tau)
            self.xlabel = fr"\{tau}"
            self.ylabel = label
            self.t_vals = np.linspace(*horiz_range_custom, self.num_points)

            self._setup_figure()
            self.setup_axes(horiz_range_custom)
            self.draw_function(horiz_range_custom)
            self.draw_impulses()
            self.draw_ticks(xticks=xticks_custom, xtick_labels=xtick_labels_custom)
            self.draw_labels()
            self.show()

    def plot_convolution_result(self, x_name, h_name, num_points=None, show_expr=False):
        """
        Computes and plots the convolution result y(t) = (x * h)(t) between two signals x(t) and h(t).

        This method automatically:
        - Detects if either x(τ) or h(t−τ) consists only of Dirac deltas, and applies the convolution property for impulses.
        - Otherwise, performs numerical integration over τ for a given range of t values.
        - Displays the resulting function y(t), including impulses if present.

        Notes:
        - Impulse responses are handled symbolically, while general functions are integrated numerically.
        - Uses scipy.integrate.quad for general convolution integrals.

        Args:
            x_name (str): Name of the signal x(t), previously defined via `add_signal(...)`.
            h_name (str): Name of the signal h(t), previously defined via `add_signal(...)`.
            num_points (int, optional): Number of time samples to compute for numerical integration. Defaults to self.num_points.
            show_expr (bool, optional): Reserved for future use; currently unused.
        """

        t = sp.Symbol('t')
        tau = sp.Symbol('tau')

        if num_points is None:
            num_points = self.num_points

        x_expr = self.signal_defs[x_name]
        h_expr = self.signal_defs[h_name]
        var_x = self.var_symbols[x_name]
        var_h = self.var_symbols[h_name]

        x_tau_expr = x_expr.subs(var_x, tau)
        h_t_tau_expr = h_expr.subs(var_h, t - tau)

        local_dict = self._get_local_dict()
        t_vals = np.linspace(*self.horiz_range, num_points)
        y_vals = []

        # Case 1: Dirac in x(t)
        if x_tau_expr.has(sp.DiracDelta):
            y_expr = 0
            for term in x_tau_expr.as_ordered_terms():
                if term.has(sp.DiracDelta):
                    args = term.args if term.func == sp.Mul else [term]
                    scale = 1
                    for a in args:
                        if a.func == sp.DiracDelta:
                            delta_arg = a.args[0]
                        else:
                            scale *= a
                    shift = sp.solve(delta_arg, tau)
                    if shift:
                        y_expr += scale * h_expr.subs(var_h, t - shift[0])

            # Extract impulses from y_expr
            impulse_locs = []
            impulse_areas = []
            for term in y_expr.as_ordered_terms():
                if term.has(sp.DiracDelta):
                    args = term.args if term.func == sp.Mul else [term]
                    area = 1
                    shift = 0
                    for a in args:
                        if a.func == sp.DiracDelta:
                            sol = sp.solve(a.args[0], t)
                            if sol:
                                shift = float(sol[0])
                        else:
                            area *= a
                    impulse_locs.append(shift)
                    impulse_areas.append(float(area))

            self._update_expression_and_func(y_expr, t)
            self.impulse_locs = impulse_locs
            self.impulse_areas = impulse_areas

        # Case 2: Dirac in h(t)
        elif h_t_tau_expr.has(sp.DiracDelta):
            y_expr = 0
            for term in h_t_tau_expr.as_ordered_terms():
                if term.has(sp.DiracDelta):
                    args = term.args if term.func == sp.Mul else [term]
                    scale = 1
                    for a in args:
                        if a.func == sp.DiracDelta:
                            delta_arg = a.args[0]
                        else:
                            scale *= a
                    shift = sp.solve(delta_arg, tau)
                    if shift:
                        y_expr += scale * x_tau_expr.subs(tau, shift[0])

            impulse_locs = []
            impulse_areas = []
            for term in y_expr.as_ordered_terms():
                if term.has(sp.DiracDelta):
                    args = term.args if term.func == sp.Mul else [term]
                    area = 1
                    shift = 0
                    for a in args:
                        if a.func == sp.DiracDelta:
                            sol = sp.solve(a.args[0], t)
                            if sol:
                                shift = float(sol[0])
                        else:
                            area *= a
                    impulse_locs.append(shift)
                    impulse_areas.append(float(area))

            self._update_expression_and_func(y_expr, t)
            self.impulse_locs = impulse_locs
            self.impulse_areas = impulse_areas

        # Case 3: General convolution via numerical integration
        else:
            x_func_tau = sp.lambdify(tau, x_tau_expr, modules=["numpy", local_dict])

            def h_func_tau_shifted(tau_val, t_val):
                h_t_tau = h_t_tau_expr.subs(t, t_val)
                h_func = sp.lambdify(tau, h_t_tau, modules=["numpy", local_dict])
                return h_func(tau_val)

            support_x = self.horiz_range
            support_h = self.horiz_range

            for t_val in t_vals:
                a = max(support_x[0], t_val - support_h[1])
                b = min(support_x[1], t_val - support_h[0])
                if a >= b:
                    y_vals.append(0)
                    continue
                integrand = lambda tau_val: x_func_tau(tau_val) * h_func_tau_shifted(tau_val, t_val)
                try:
                    val, _ = integrate.quad(integrand, a, b)
                except Exception:
                    val = 0
                y_vals.append(val)

            self.func = lambda t_: np.interp(t_, t_vals, y_vals)
            self.impulse_locs = []
            self.impulse_areas = []
            self.expr = None

        # Final settings and plot
        self.t_vals = t_vals
        self.xlabel = "t"
        self.ylabel = r"y(t)"

        self._setup_figure()
        self.setup_axes()
        self.draw_function()
        self.draw_impulses()
        self.draw_ticks()
        self.draw_labels()
        self.show()

__init__(expr_str=None, horiz_range=(-5, 5), vert_range=None, period=None, num_points=1000, figsize=(8, 3), tick_size_px=5, xticks='auto', yticks='auto', xtick_labels=None, ytick_labels=None, xticks_delta=None, yticks_delta=None, pi_mode=False, fraction_ticks=False, save_path=None, show_plot=True, color='black', alpha=0.5)

(Private) Creator of the SignalPlotter class.

Source code in signalblocks\SignalPlotter.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def __init__(
    self,
    expr_str=None, 
    horiz_range=(-5, 5),
    vert_range=None,
    period=None,
    num_points=1000,
    figsize=(8, 3), 
    tick_size_px=5,
    xticks='auto',
    yticks='auto',
    xtick_labels=None,
    ytick_labels=None,
    xticks_delta=None,
    yticks_delta=None,
    pi_mode=False,
    fraction_ticks=False,
    save_path=None, 
    show_plot=True,
    color='black', 
    alpha=0.5 
):
    """
    (Private) Creator of the SignalPlotter class.
    """
    self.signal_defs = {}
    self.var_symbols = {}
    self.current_name = None
    self.horiz_range = horiz_range
    self.vert_range = vert_range
    self.num_points = num_points
    self.figsize = figsize
    self.tick_size_px = tick_size_px
    self.color = color
    self.alpha = alpha
    self.period = period
    self.save_path = save_path
    self.show_plot = show_plot

    self.fraction_ticks = fraction_ticks

    # Preserve original tick arguments to differentiate None / [] / 'auto'
    self.init_xticks_arg = xticks
    self.init_yticks_arg = yticks

    if isinstance(xticks, (list, tuple, np.ndarray)) and len(xticks) > 0:
        self.xticks = np.array(xticks)
    else:
        self.xticks = None
    if isinstance(yticks, (list, tuple, np.ndarray)) and len(yticks) > 0:
        self.yticks = np.array(yticks)
    else:
        self.yticks = None

    self.pi_mode = pi_mode

    self.xtick_labels = xtick_labels
    self.ytick_labels = ytick_labels

    if self.xtick_labels is not None:
        if self.xticks is None:
            raise ValueError("xtick_labels provided without xticks positions")
        if len(self.xtick_labels) != len(self.xticks):
            raise ValueError("xtick_labels and xticks must have the same length")
    if self.ytick_labels is not None:
        if self.yticks is None:
            raise ValueError("ytick_labels provided without yticks positions")
        if len(self.ytick_labels) != len(self.yticks):
            raise ValueError("ytick_labels and yticks must have the same length")

    self.xticks_delta = xticks_delta
    self.yticks_delta = yticks_delta

    self.expr_str_pending = expr_str  # Expression to initialize later if plot() is called first

    self.transformations = (standard_transformations + (implicit_multiplication_application,))

add_signal(expr_str, label=None, period=None)

Adds a new signal to the internal dictionary for later plotting. - The expression is parsed symbolically using SymPy. - If other signals are referenced, their definitions are recursively substituted. - If period is set, the signal will be expanded as a sum of time-shifted versions over the full horizontal range.

Parameters:

Name Type Description Default
expr_str str

Signal definition in the form "name(var) = expression", e.g. "x(t) = rect(t) + delta(t-1)". The expression may include previously defined signals.

required
label str

Custom label for the vertical axis when plotting this signal.

None
period float

If provided, the signal will be treated as periodic with this period.

None

Examples:

>>> sp = SignalPlotter(horiz_range=(-1, 1), fraction_ticks=True, figsize=(12, 4))
>>> sp.add_signal("x1(t) = tri(t)")
>>> sp.add_signal("x2(t) = delta(t)", period=0.2)
>>> sp.add_signal("x3(t) = x1(t) * (1 + x2(t))", label="x_3(t)")
>>> sp.plot("x3")
>>> sp.add_signal("x4(t) = exp((-2+j*8*pi)*t)*u(t)")
>>> sp.add_signal("x5(t) = re(x4(t))", label="\Re\{x_4(t)\}")
>>> sp.plot('x5')
Source code in signalblocks\SignalPlotter.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def add_signal(self, expr_str, label=None, period=None):
    r"""
    Adds a new signal to the internal dictionary for later plotting.
    - The expression is parsed symbolically using SymPy.
    - If other signals are referenced, their definitions are recursively substituted.
    - If `period` is set, the signal will be expanded as a sum of time-shifted versions over the full horizontal range.

    Args:
        expr_str (str): Signal definition in the form "name(var) = expression", e.g. "x(t) = rect(t) + delta(t-1)". The expression may include previously defined signals.
        label (str, optional): Custom label for the vertical axis when plotting this signal.
        period (float, optional): If provided, the signal will be treated as periodic with this period.

    Examples:
        >>> sp = SignalPlotter(horiz_range=(-1, 1), fraction_ticks=True, figsize=(12, 4))
        >>> sp.add_signal("x1(t) = tri(t)")
        >>> sp.add_signal("x2(t) = delta(t)", period=0.2)
        >>> sp.add_signal("x3(t) = x1(t) * (1 + x2(t))", label="x_3(t)")
        >>> sp.plot("x3")
        >>> sp.add_signal("x4(t) = exp((-2+j*8*pi)*t)*u(t)")
        >>> sp.add_signal("x5(t) = re(x4(t))", label="\Re\{x_4(t)\}")
        >>> sp.plot('x5')
    """
    m = re.match(r"^(?P<fn>\w+)\((?P<vr>\w+)\)\s*=\s*(?P<ex>.+)$", expr_str)

    replacements = {'\\omega': 'ω', '\\tau': 'τ'}
    for latex_var, unicode_var in replacements.items():
        expr_str = expr_str.replace(latex_var, unicode_var)
    m = re.match(r"^(?P<fn>\w+)\((?P<vr>\w+)\)\s*=\s*(?P<ex>.+)$", expr_str)

    name = m.group('fn')
    var = m.group('vr')
    body = m.group('ex')

    if var not in self.var_symbols:
        self.var_symbols[var] = sp.Symbol(var)
    var_sym = self.var_symbols[var]

    local_dict = self._get_local_dict()
    for other_name in self.signal_defs:
        local_dict[other_name] = sp.Function(other_name)

    transformations = standard_transformations + (implicit_multiplication_application,)
    parsed_expr = parse_expr(body, local_dict=local_dict, transformations=transformations)

    for other_name, other_expr in self.signal_defs.items():
        f = sp.Function(other_name)
        matches = parsed_expr.find(f)
        for call in matches:
            if isinstance(call, sp.Function):
                arg = call.args[0]
                replaced = other_expr.subs(var_sym, arg)
                parsed_expr = parsed_expr.subs(call, replaced)

    self.signal_defs[name] = parsed_expr
    self.var_symbols[name] = var_sym

    if label is not None:
        if not hasattr(self, 'custom_labels'):
            self.custom_labels = {}
        self.custom_labels[name] = label

    if period is not None:
        if not hasattr(self, 'signal_periods'):
            self.signal_periods = {}
        self.signal_periods[name] = period

        # Expand signal as sum of shifts within range
        horiz_min, horiz_max = self.horiz_range
        num_periods = int(np.ceil((horiz_max - horiz_min) / period))
        k_range = range(-num_periods - 2, num_periods + 3)  # márgenes extra

        # Expanded as sum of shifted expressions (in SymPy)
        expanded_expr = sum(parsed_expr.subs(var_sym, var_sym - period * k) for k in k_range)

        self.signal_defs[name] = expanded_expr
    else:
        self.signal_defs[name] = parsed_expr

draw_function(horiz_range=None)

Plots the continuous part of the signal over the specified horizontal range. This method is typically called after setup_axes(). This method is usually called internally from plot(), but can also be used manually.

This method: - Evaluates the function defined in self.func across self.t_vals. - Plots the result as a smooth curve using the configured color and linewidth. - Automatically detects and adds ellipsis ("⋯") on the left/right ends if: - The signal is marked as periodic, or - Significant energy exists just outside the plotting range.

Notes: - This method does not draw delta impulses. Use draw_impulses() for that. - Ellipsis are drawn at 5% beyond the plot edges when appropriate.

Parameters:

Name Type Description Default
horiz_range tuple

Tuple (t_min, t_max) to override the default horizontal range. If None, uses self.horiz_range.

None

Examples:

>>> self.draw_function()
>>> self.draw_impulses()  # to add deltas on top of the curve
Source code in signalblocks\SignalPlotter.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
def draw_function(self, horiz_range=None):
    """
    Plots the continuous part of the signal over the specified horizontal range.
    This method is typically called after `setup_axes()`.
    This method is usually called internally from `plot()`, but can also be used manually.

    This method:
    - Evaluates the function defined in `self.func` across `self.t_vals`.
    - Plots the result as a smooth curve using the configured color and linewidth.
    - Automatically detects and adds ellipsis ("⋯") on the left/right ends if:
        - The signal is marked as periodic, or
        - Significant energy exists just outside the plotting range.

    Notes:
    - This method does not draw delta impulses. Use `draw_impulses()` for that.
    - Ellipsis are drawn at 5% beyond the plot edges when appropriate.

    Args:
        horiz_range (tuple, optional): Tuple (t_min, t_max) to override the default horizontal range. If None, uses `self.horiz_range`.

    Examples:
        >>> self.draw_function()
        >>> self.draw_impulses()  # to add deltas on top of the curve
    """

    if horiz_range is None:
        horiz_range = self.horiz_range

    t0, t1 = horiz_range
    t_plot = self.t_vals
    y_plot = self._eval_func_array(t_plot)

    # Assure arrays and format
    t_plot = np.array(t_plot)
    y_plot = np.array(y_plot)
    if y_plot.ndim == 0:
        y_plot = np.full_like(t_plot, y_plot, dtype=float)

    # Plot curve
    self.ax.plot(t_plot, y_plot, color=self.color, linewidth=2.5, zorder=5)

    # Decide whether to draw ellipsis
    delta = (t1 - t0) * 0.05
    tol = 1e-3
    span = t1 - t0
    draw_left = draw_right = False

    # Show alwais if periodic
    if hasattr(self, 'signal_periods') and self.current_name in self.signal_periods:
        draw_left = draw_right = True
    else:
        N = max(10, int(0.05 * self.num_points))
        xs_left = np.linspace(t0 - 0.05 * span, t0, N)
        ys_left = np.abs(self._eval_func_array(xs_left))
        if np.trapz(ys_left, xs_left) > tol:
            draw_left = True

        xs_right = np.linspace(t1, t1 + 0.05 * span, N)
        ys_right = np.abs(self._eval_func_array(xs_right))
        if np.trapz(ys_right, xs_right) > tol:
            draw_right = True

    # Draw ellipsis if needed
    y_mid = (self.y_min + 2 * self.y_max) / 3
    if draw_left:
        self.ax.text(t0 - delta, y_mid, r'$\cdots$', ha='left', va='center',
                    color=self.color, fontsize=14, zorder=10)
    if draw_right:
        self.ax.text(t1 + delta, y_mid, r'$\cdots$', ha='right', va='center',
                    color=self.color, fontsize=14, zorder=10)

draw_impulses()

Draws Dirac delta impulses at the extracted positions and amplitudes. This method is typically called after draw_functions(). This method is usually called internally from plot(), but can also be used manually.

This method: - Iterates over the list of impulse locations (self.impulse_locs) and their corresponding amplitudes (self.impulse_areas). - Calls _draw_single_impulse() for each impulse located within the current horizontal plotting range (self.horiz_range).

Notes: - This method only draws impulses that are visible within the plotting window. - Periodicity is not assumed. Use add_signal(..., period=...) to manually expand periodic impulses. - The drawing includes both a vertical arrow and a bold label showing the impulse area.

Examples:

>>> self.draw_function()
>>> self.draw_impulses()
Source code in signalblocks\SignalPlotter.py
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def draw_impulses(self):
    """
    Draws Dirac delta impulses at the extracted positions and amplitudes.
    This method is typically called after `draw_functions()`.
    This method is usually called internally from `plot()`, but can also be used manually.

    This method:
    - Iterates over the list of impulse locations (`self.impulse_locs`)
    and their corresponding amplitudes (`self.impulse_areas`).
    - Calls `_draw_single_impulse()` for each impulse located within
    the current horizontal plotting range (`self.horiz_range`).

    Notes:
    - This method only draws impulses that are visible within the plotting window.
    - Periodicity is not assumed. Use `add_signal(..., period=...)` to manually expand periodic impulses.
    - The drawing includes both a vertical arrow and a bold label showing the impulse area.

    Examples:
        >>> self.draw_function()
        >>> self.draw_impulses()
    """
    t_min, t_max = self.horiz_range
    for t0, amp in zip(self.impulse_locs, self.impulse_areas):
        if t_min <= t0 <= t_max:
            self._draw_single_impulse(t0, amp)

draw_labels()

Adds axis labels to the x and y axes using the current signal variable and name. This method is typically called after draw_ticks(). This method is usually called internally from plot(), but can also be used manually.

This method: - Retrieves the current axis limits. - Places the x-axis label slightly to the right of the horizontal axis arrow. - Places the y-axis label slightly below the top of the vertical axis arrow. - Uses LaTeX formatting for the labels (e.g., "x(t)", "y(\tau)", etc.). - The labels use the values of self.xlabel and self.ylabel.

Notes: - This method is called automatically in plot() after drawing ticks and arrows. - Rotation for y-axis label is disabled to keep it horizontal.

Examples:

>>> self.draw_labels()
Source code in signalblocks\SignalPlotter.py
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
def draw_labels(self):
    """
    Adds axis labels to the x and y axes using the current signal variable and name.
    This method is typically called after `draw_ticks()`.
    This method is usually called internally from `plot()`, but can also be used manually.

    This method:
    - Retrieves the current axis limits.
    - Places the x-axis label slightly to the right of the horizontal axis arrow.
    - Places the y-axis label slightly below the top of the vertical axis arrow.
    - Uses LaTeX formatting for the labels (e.g., "x(t)", "y(\\tau)", etc.).
    - The labels use the values of `self.xlabel` and `self.ylabel`.

    Notes:
    - This method is called automatically in `plot()` after drawing ticks and arrows.
    - Rotation for y-axis label is disabled to keep it horizontal.

    Examples:
        >>> self.draw_labels()
    """
    # Get the current x and y axis limits
    x_lim = self.ax.get_xlim()
    y_lim = self.ax.get_ylim()

    # X-axis label: slightly to the right of the rightmost x limit
    x_pos = x_lim[1] - 0.01 * (x_lim[1] - x_lim[0])
    y_pos = 0.02 * (y_lim[1] - y_lim[0])
    self.ax.text(x_pos, y_pos, rf'${self.xlabel}$', fontsize=16, ha='right', va='bottom')

    # Y-axis label: slightly below the top y limit (still inside the figure)
    x_pos = 0.01 * (x_lim[1] - x_lim[0])
    y_pos = y_lim[1] - 0.1 * (y_lim[1] - y_lim[0])
    self.ax.text(x_pos, y_pos, rf'${self.ylabel}$', fontsize=16, ha='left', va='bottom', rotation=0)

draw_ticks(tick_size_px=None, xticks=None, yticks=None, xtick_labels='auto', ytick_labels='auto')

Draws tick marks and labels on both x- and y-axes, using automatic or manual configurations. This method is typically called after draw_impulses(). This method is usually called internally from plot(), but can also be used manually.

Features: - Adds tick marks and LaTeX-formatted labels. - Integrates impulse positions into xticks automatically, unless explicitly overridden. - Supports: - pi_mode: labels as multiples of π. - fraction_ticks: labels as rational fractions. - Hiding y=0 label if x=0 tick is shown (to avoid overlapping at the origin). - Avoids duplicate tick values based on numerical tolerance.

Notes: - If xticks_delta or yticks_delta are provided (in constructor), evenly spaced ticks are placed at multiples. - If custom labels are passed and their count does not match the tick list, raises ValueError. - Labels are drawn on white rounded boxes for visibility over plots.

Parameters:

Name Type Description Default
tick_size_px int

Length of tick marks in pixels. If None, uses self.tick_size_px.

None
xticks list or auto or None

X-axis tick positions. - 'auto': automatically computed ticks (even spacing or using xticks_delta). - list: manually specified tick positions. - None: ticks are shown only at Dirac impulse positions.

None
yticks list or auto or None

Y-axis tick positions. - Same behavior as xticks.

None
xtick_labels list or auto or None

Custom labels for xticks (must match length of xticks).

'auto'
ytick_labels list or auto or None

Same for yticks.

'auto'

Examples:

>>> self.draw_ticks(xticks='auto', yticks=[-1, 0, 1], ytick_labels=['-1', '0', '1'])
Source code in signalblocks\SignalPlotter.py
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
def draw_ticks(self,
            tick_size_px=None,
            xticks=None,
            yticks=None,
            xtick_labels='auto',
            ytick_labels='auto'):
    """
    Draws tick marks and labels on both x- and y-axes, using automatic or manual configurations.
    This method is typically called after `draw_impulses()`.
    This method is usually called internally from `plot()`, but can also be used manually.

    Features:
    - Adds tick marks and LaTeX-formatted labels.
    - Integrates impulse positions into xticks automatically, unless explicitly overridden.
    - Supports:
        - `pi_mode`: labels as multiples of π.
        - `fraction_ticks`: labels as rational fractions.
        - Hiding y=0 label if x=0 tick is shown (to avoid overlapping at the origin).
    - Avoids duplicate tick values based on numerical tolerance.

    Notes:
    - If `xticks_delta` or `yticks_delta` are provided (in constructor), evenly spaced ticks are placed at multiples.
    - If custom labels are passed and their count does not match the tick list, raises ValueError.
    - Labels are drawn on white rounded boxes for visibility over plots.

    Args:
        tick_size_px (int, optional): Length of tick marks in pixels. If None, uses self.tick_size_px.
        xticks (list or 'auto' or None): X-axis tick positions.
            - 'auto': automatically computed ticks (even spacing or using xticks_delta).
            - list: manually specified tick positions.
            - None: ticks are shown only at Dirac impulse positions.
        yticks (list or 'auto' or None): Y-axis tick positions.
            - Same behavior as `xticks`.
        xtick_labels (list or 'auto' or None): Custom labels for xticks (must match length of xticks).
        ytick_labels (list or 'auto' or None): Same for yticks.

    Examples:
        >>> self.draw_ticks(xticks='auto', yticks=[-1, 0, 1], ytick_labels=['-1', '0', '1'])
    """

    # Helper: filter duplicate values with tolerance
    def unique_sorted(values, tol):
        unique = []
        for v in values:
            if not any(abs(v - u) <= tol for u in unique):
                unique.append(v)
        return sorted(unique)

    # Helper: get impulse locations and amplitudes within range (with periodic extension if needed)
    def get_impulse_positions_and_areas(t_min, t_max, tol):
        impulse_positions = []
        impulse_positions_areas = []
        if self.impulse_locs:
            if self.period is None:
                # Non-periodic case: keep impulses in visible range
                for base_loc, base_area in zip(self.impulse_locs, self.impulse_areas):
                    if t_min - tol <= base_loc <= t_max + tol:
                        impulse_positions.append(base_loc)
                        impulse_positions_areas.append(base_area)
            else:
                # Periodic case: replicate impulses across periods
                T = self.period
                for base_loc, base_area in zip(self.impulse_locs, self.impulse_areas):
                    k_min = int(np.floor((t_min - base_loc) / T))
                    k_max = int(np.ceil((t_max - base_loc) / T))
                    for k in range(k_min, k_max + 1):
                        t_k = base_loc + k * T
                        if t_min - tol <= t_k <= t_max + tol:
                            impulse_positions.append(t_k)
                            impulse_positions_areas.append(base_area)
        # Eliminate duplicates within tolerance
        unique_pos = []
        unique_area = []
        for loc, area in zip(impulse_positions, impulse_positions_areas):
            if not any(abs(loc - u) <= tol for u in unique_pos):
                unique_pos.append(loc)
                unique_area.append(area)
        if unique_pos:
            idx_sort = np.argsort(unique_pos)
            impulse_positions = [unique_pos[i] for i in idx_sort]
            impulse_positions_areas = [unique_area[i] for i in idx_sort]
        else:
            impulse_positions, impulse_positions_areas = [], []
        return impulse_positions, impulse_positions_areas

    # Helper: validate tick list
    def has_valid_ticks(ticks):
        if ticks is None:
            return False
        try:
            arr = np.array(ticks)
            return arr.ndim >= 1 and arr.size >= 1
        except Exception:
            return False

    # Helper: generate xticks and labels
    def generate_xticks(effective_xticks, impulse_positions, tol, t_min, t_max):
        raw_xticks = []
        manual_xticks = []
        manual_xlabels = []

        has_init_xticks = has_valid_ticks(getattr(self, 'xticks', None))
        xticks_delta = getattr(self, 'xticks_delta', None)  # Nuevo atributo opcional

        if isinstance(effective_xticks, str) and effective_xticks == 'auto':
            if has_init_xticks:
                raw_xticks = list(self.xticks)
                if self.xtick_labels is not None:
                    if len(self.xticks) != len(self.xtick_labels):
                        raise ValueError("xtick_labels and xticks from init must have the same length")
                    manual_xticks = list(self.xticks)
                    manual_xlabels = list(self.xtick_labels)
            else:
                if xticks_delta is not None:
                    n_left = int(np.floor((0 - t_min) / xticks_delta))
                    n_right = int(np.floor((t_max - 0) / xticks_delta))
                    base_ticks = [k * xticks_delta for k in range(-n_left, n_right + 1)]
                else:
                    base_ticks = list(np.linspace(t_min, t_max, 5))
                raw_xticks = base_ticks.copy()

            # Add impulses
            for loc in impulse_positions:
                if t_min - tol <= loc <= t_max + tol and not any(abs(loc - x0) <= tol for x0 in raw_xticks):
                    raw_xticks.append(loc)

        else:
            if xticks_delta is not None:
                warnings.warn("xticks_delta will be ignored because xticks not in 'auto' mode", stacklevel=2)
            raw_xticks = list(effective_xticks)
            if xtick_labels not in (None, 'auto'):
                if len(raw_xticks) != len(xtick_labels):
                    raise ValueError("xtick_labels and xticks must have the same length")
                manual_xticks = list(raw_xticks)
                manual_xlabels = list(xtick_labels)
            elif self.xtick_labels is not None:
                if len(raw_xticks) != len(self.xtick_labels):
                    raise ValueError("xtick_labels and xticks from init must have the same length")
                manual_xticks = list(raw_xticks)
                manual_xlabels = list(self.xtick_labels)

            for loc in impulse_positions:
                if t_min - tol <= loc <= t_max + tol and not any(abs(loc - x0) <= tol for x0 in raw_xticks):
                    raw_xticks.append(loc)

        raw_xticks = unique_sorted(raw_xticks, tol)

        # Gnerate labels
        xlabels = []
        for x in raw_xticks:
            label = None
            for xm, lbl in zip(manual_xticks, manual_xlabels):
                if abs(xm - x) <= tol:
                    label = lbl
                    break
            if label is None:
                if getattr(self, 'pi_mode', False):
                    f = Fraction(x / np.pi).limit_denominator(24)
                    if abs(float(f) * np.pi - x) > tol:
                        label = f'{x:g}'
                    elif f == 0:
                        label = '0'
                    elif f == 1:
                        label = r'\pi'
                    elif f == -1:
                        label = r'-\pi'
                    else:
                        num = f.numerator
                        denom = f.denominator
                        prefix = '-' if num * denom < 0 else ''
                        num, denom = abs(num), abs(denom)
                        if denom == 1:
                            label = rf"{prefix}{num}\pi"
                        elif num == 1:
                            label = rf"{prefix}\frac{{\pi}}{{{denom}}}"
                        else:
                            label = rf"{prefix}\frac{{{num}\pi}}{{{denom}}}"
                elif getattr(self, 'fraction_ticks', False):
                    f = Fraction(x).limit_denominator(24)
                    label = f"{f.numerator}/{f.denominator}" if f.denominator != 1 else f"{f.numerator}"
                else:
                    label = f'{x:g}'
            xlabels.append(label)

        return raw_xticks, xlabels

    # Helper: generate yticks and labels
    def generate_yticks(effective_yticks, tol):
        raw_yticks = []
        manual_yticks = []
        manual_ylabels = []

        has_init_yticks = has_valid_ticks(getattr(self, 'yticks', None))
        ytick_labels = getattr(self, 'ytick_labels', None)
        ydelta = getattr(self, 'yticks_delta', None)

        if effective_yticks is None:
            raw_yticks = []
        elif isinstance(effective_yticks, str) and effective_yticks == 'auto':
            if has_init_yticks:
                raw_yticks = list(self.yticks)
                if self.ytick_labels is not None:
                    if len(self.yticks) != len(self.ytick_labels):
                        raise ValueError("ytick_labels and yticks from init must have the same length")
                    manual_yticks = list(self.yticks)
                    manual_ylabels = list(self.ytick_labels)
                if ydelta is not None:
                    warnings.warn("yticks_delta ignored because yticks where specified at init")
            else:
                if ydelta is not None and ydelta > 0:
                    y_start = np.ceil(self.y_min / ydelta)
                    y_end = np.floor(self.y_max / ydelta)
                    raw_yticks = [k * ydelta for k in range(int(y_start), int(y_end) + 1)]
                else:
                    y0 = np.floor(self.y_min)
                    y1 = np.ceil(self.y_max)
                    if abs(y1 - y0) < 1e-6:
                        raw_yticks = [y0 - 1, y0, y0 + 1]
                    else:
                        raw_yticks = list(np.linspace(y0, y1, 3))
        else:
            raw_yticks = list(effective_yticks)
            if ydelta is not None:
                warnings.warn("yticks_delta ignored because yticks is not in 'auto' mode")

            if ytick_labels not in (None, 'auto'):
                if len(raw_yticks) != len(ytick_labels):
                    raise ValueError("ytick_labels and yticks must have the same length")
                manual_yticks = list(raw_yticks)
                manual_ylabels = list(ytick_labels)
            elif self.ytick_labels is not None:
                if len(raw_yticks) != len(self.ytick_labels):
                    raise ValueError("ytick_labels and yticks from init must have the same length")
                manual_yticks = list(raw_yticks)
                manual_ylabels = list(self.ytick_labels)

        raw_yticks = unique_sorted(raw_yticks, tol)

        ylabels = []
        for y in raw_yticks:
            label = None
            for ym, lbl in zip(manual_yticks, manual_ylabels):
                if abs(ym - y) <= tol:
                    label = lbl
                    break
            if label is None:
                if self.pi_mode:
                    f = Fraction(y / np.pi).limit_denominator(24)
                    if abs(float(f) * np.pi - y) > tol:
                        label = f'{y:.3g}'
                    elif f == 0:
                        label = '0'
                    elif f == 1:
                        label = r'\pi'
                    elif f == -1:
                        label = r'-\pi'
                    else:
                        num = f.numerator
                        denom = f.denominator
                        prefix = '-' if num * denom < 0 else ''
                        num, denom = abs(num), abs(denom)
                        if denom == 1:
                            label = rf"{prefix}{num}\pi"
                        elif num == 1:
                            label = rf"{prefix}\frac{{\pi}}{{{denom}}}"
                        else:
                            label = rf"{prefix}\frac{{{num}\pi}}{{{denom}}}"
                elif self.fraction_ticks:
                    f = Fraction(y).limit_denominator(24)
                    label = f"{f.numerator}/{f.denominator}" if f.denominator != 1 else f"{f.numerator}"
                else:
                    label = f'{y:.3g}'
            ylabels.append(label)

        return raw_yticks, ylabels

    # Helper: hide y=0 label if x=0 tick exists
    def filter_yticks(raw_yticks, ylabels, raw_xticks, tol):
        has_xtick_zero = any(abs(x) <= tol for x in raw_xticks)
        if has_xtick_zero:
            filtered_yticks = []
            filtered_ylabels = []
            for y, lbl in zip(raw_yticks, ylabels):
                if abs(y) <= tol:
                    continue
                filtered_yticks.append(y)
                filtered_ylabels.append(lbl)
            return filtered_yticks, filtered_ylabels
        else:
            return raw_yticks, ylabels

    # Helper: convert pixel length to data coordinates
    def px_to_data_length(tick_px):
        origin_disp = self.ax.transData.transform((0, 0))
        up_disp = origin_disp + np.array([0, tick_px])
        right_disp = origin_disp + np.array([tick_px, 0])
        origin_data = np.array(self.ax.transData.inverted().transform(origin_disp))
        up_data = np.array(self.ax.transData.inverted().transform(up_disp))
        right_data = np.array(self.ax.transData.inverted().transform(right_disp))
        dy = up_data[1] - origin_data[1]
        dx = right_data[0] - origin_data[0]
        return dx, dy

    # Helper: draw xticks and labels
    def draw_xticks(raw_xticks, xlabels, impulse_positions, impulse_positions_areas, dx, dy, tol):
        xlim = self.ax.get_xlim()
        for x, lbl in zip(raw_xticks, xlabels):
            if xlim[0] <= x <= xlim[1]:
                self.ax.plot([x, x], [0 - dy/2, 0 + dy/2], transform=self.ax.transData,
                            color='black', linewidth=1.2, clip_on=False)
                area = None
                for loc, a in zip(impulse_positions, impulse_positions_areas):
                    if abs(loc - x) <= tol:
                        area = a
                        break
                y_off = +8 if (area is not None and area < 0) else -8
                offset = (-8, y_off) if abs(x) < tol else (0, y_off)
                va = 'bottom' if y_off > 0 else 'top'
                self.ax.annotate(rf'${lbl}$', xy=(x, 0), xycoords='data',
                                textcoords='offset points', xytext=offset,
                                ha='center', va=va, fontsize=12, zorder=10,
                                bbox=dict(boxstyle='round,pad=0.1', facecolor='white',
                                        edgecolor='none', alpha=self.alpha))

    # Helper: draw yticks and labels
    def draw_yticks(raw_yticks, ylabels, dx, dy):
        ylim = self.ax.get_ylim()
        for y, lbl in zip(raw_yticks, ylabels):
            if ylim[0] <= y <= ylim[1]:
                self.ax.plot([0 - dx/2, 0 + dx/2], [y, y], transform=self.ax.transData,
                            color='black', linewidth=1.2, clip_on=False)
                offset = (-4, -16) if abs(y) < 1e-10 else (-4, 0)
                self.ax.annotate(rf'${lbl}$', xy=(0, y), xycoords='data',
                                textcoords='offset points', xytext=offset,
                                ha='right', va='center', fontsize=12, zorder=10,
                                bbox=dict(boxstyle='round,pad=0.1', facecolor='white',
                                        edgecolor='none', alpha=self.alpha))

     # === MAIN LOGIC ===

    # 0. Use constructor defaults if nothing passed explicitly
    effective_xticks = xticks if xticks is not None else getattr(self, 'init_xticks_arg', None)
    effective_yticks = yticks if yticks is not None else getattr(self, 'init_yticks_arg', None)

    # 1. Determine tick size in pixels
    tick_px = tick_size_px if tick_size_px is not None else self.tick_size_px

    # 2. Get plotting range and numeric tolerance
    t_min, t_max = self.horiz_range
    tol = 1e-8 * max(1.0, abs(t_max - t_min))

    # 3. Get impulse positions in the current range
    impulse_positions, impulse_positions_areas = get_impulse_positions_and_areas(t_min, t_max, tol)

    # 4. Generate x ticks and labels
    raw_xticks, xlabels = generate_xticks(effective_xticks, impulse_positions, tol, t_min, t_max)

    # 5. Generate y ticks and labels
    raw_yticks, ylabels = generate_yticks(effective_yticks, tol)

    # 6. Remove y=0 label if x=0 tick exists
    raw_yticks, ylabels = filter_yticks(raw_yticks, ylabels, raw_xticks, tol)

    # 7. Convert tick length in px to data coordinates
    dx, dy = px_to_data_length(tick_px)

    # 8. Draw x-axis ticks and labels
    draw_xticks(raw_xticks, xlabels, impulse_positions, impulse_positions_areas, dx, dy, tol)

    # 9. Draw y-axis ticks and labels
    draw_yticks(raw_yticks, ylabels, dx, dy)

plot(name=None)

Plots the signal specified by name (or the default if defined via expr_str in constructor).

This method: - Initializes the expression from expr_str_pending (if any), only once. - Looks up the signal in the internal dictionary (self.signal_defs) using its name. - Sets up symbolic and numeric representations of the signal. - Removes DiracDelta terms from the continuous part. - Extracts impulses and prepares data for plotting. - Calls the full sequence: axis setup, function drawing, impulses, ticks, labels, and final display/save.

Parameters:

Name Type Description Default
name str

Name of the signal to plot, e.g., "x1". If None and expr_str was given at init, it uses the last-added expression.

None

Raises:

Type Description
ValueError

If the signal is not defined or its variable cannot be determined.

Examples:

>>> SignalPlotter("x(t)=rect(t-1)").plot()
>>> sp1 = SignalPlotter("x(t)=rect(t-1)", period=2)
>>> sp1.plot("x")
>>> sp2 = SignalPlotter()
>>> sp2.add_signal("x(t) = rect(t)")
>>> sp2.plot("x")
Source code in signalblocks\SignalPlotter.py
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
def plot(self, name=None):
    """
    Plots the signal specified by name (or the default if defined via expr_str in constructor).

    This method:
    - Initializes the expression from `expr_str_pending` (if any), only once.
    - Looks up the signal in the internal dictionary (`self.signal_defs`) using its name.
    - Sets up symbolic and numeric representations of the signal.
    - Removes DiracDelta terms from the continuous part.
    - Extracts impulses and prepares data for plotting.
    - Calls the full sequence: axis setup, function drawing, impulses, ticks, labels, and final display/save.

    Args:
        name (str, optional): Name of the signal to plot, e.g., "x1". If None and `expr_str` was given at init,
                    it uses the last-added expression.

    Raises:
        ValueError: If the signal is not defined or its variable cannot be determined.

    Examples:
        >>> SignalPlotter("x(t)=rect(t-1)").plot()
        >>> sp1 = SignalPlotter("x(t)=rect(t-1)", period=2)
        >>> sp1.plot("x")
        >>> sp2 = SignalPlotter()
        >>> sp2.add_signal("x(t) = rect(t)")
        >>> sp2.plot("x")
    """
    # Initialize from expr_str (only once), if provided at construction
    if (hasattr(self, 'expr_str_pending') and 
        self.expr_str_pending is not None and 
        isinstance(self.expr_str_pending, str) and 
        not getattr(self, '_initialized_from_expr', False)):
        expr_str = self.expr_str_pending
        self._initialized_from_expr = True
        self.add_signal(expr_str, period=self.period)
        name = list(self.signal_defs.keys())[-1]

    if name:
        if name not in self.signal_defs:
            raise ValueError(f"Signal '{name}' is not defined.")
        self.current_name = name
        self.func_name = name

        # Use declared variable or infer it
        expr = self.signal_defs[name]
        var = self.var_symbols.get(name, None)
        if var is None:
            free_vars = list(expr.free_symbols)
            if not free_vars:
                raise ValueError(f"Could not determine the variable for signal '{name}'.")
            var = free_vars[0]

        # Update expression and lambdified function, remove Dirac terms, extract impulses
        self._update_expression_and_func(expr, var)

        # Use declared variable or infer it
        expr = self.signal_defs[name]
        var = self.var_symbols.get(name, None)
        if var is None:
            free_vars = list(expr.free_symbols)
            if not free_vars:
                raise ValueError(f"Could not determine the variable for signal '{name}'.")
            var = free_vars[0]

        # Update expression and lambdified function, remove Dirac terms, extract impulses
        self._update_expression_and_func(expr, var)

        # Set axis labels
        self.xlabel = str(var)
        self.ylabel = f"{self.func_name}({self.xlabel})"
        if hasattr(self, 'custom_labels') and self.func_name in self.custom_labels:
            self.ylabel = self.custom_labels[self.func_name]

        # Time discretization for plotting
        self.t_vals = np.linspace(*self.horiz_range, self.num_points)

        # Create figure and compute y-range
        self.fig, self.ax = plt.subplots(figsize=self.figsize)
        self._prepare_plot()

    # Draw all components of the plot
    self.setup_axes()
    self.draw_function()
    self.draw_impulses()
    self.ax.relim()
    self.ax.autoscale_view()
    self.draw_ticks()
    self.draw_labels()
    self.show()

plot_convolution_result(x_name, h_name, num_points=None, show_expr=False)

Computes and plots the convolution result y(t) = (x * h)(t) between two signals x(t) and h(t).

This method automatically: - Detects if either x(τ) or h(t−τ) consists only of Dirac deltas, and applies the convolution property for impulses. - Otherwise, performs numerical integration over τ for a given range of t values. - Displays the resulting function y(t), including impulses if present.

Notes: - Impulse responses are handled symbolically, while general functions are integrated numerically. - Uses scipy.integrate.quad for general convolution integrals.

Parameters:

Name Type Description Default
x_name str

Name of the signal x(t), previously defined via add_signal(...).

required
h_name str

Name of the signal h(t), previously defined via add_signal(...).

required
num_points int

Number of time samples to compute for numerical integration. Defaults to self.num_points.

None
show_expr bool

Reserved for future use; currently unused.

False
Source code in signalblocks\SignalPlotter.py
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
def plot_convolution_result(self, x_name, h_name, num_points=None, show_expr=False):
    """
    Computes and plots the convolution result y(t) = (x * h)(t) between two signals x(t) and h(t).

    This method automatically:
    - Detects if either x(τ) or h(t−τ) consists only of Dirac deltas, and applies the convolution property for impulses.
    - Otherwise, performs numerical integration over τ for a given range of t values.
    - Displays the resulting function y(t), including impulses if present.

    Notes:
    - Impulse responses are handled symbolically, while general functions are integrated numerically.
    - Uses scipy.integrate.quad for general convolution integrals.

    Args:
        x_name (str): Name of the signal x(t), previously defined via `add_signal(...)`.
        h_name (str): Name of the signal h(t), previously defined via `add_signal(...)`.
        num_points (int, optional): Number of time samples to compute for numerical integration. Defaults to self.num_points.
        show_expr (bool, optional): Reserved for future use; currently unused.
    """

    t = sp.Symbol('t')
    tau = sp.Symbol('tau')

    if num_points is None:
        num_points = self.num_points

    x_expr = self.signal_defs[x_name]
    h_expr = self.signal_defs[h_name]
    var_x = self.var_symbols[x_name]
    var_h = self.var_symbols[h_name]

    x_tau_expr = x_expr.subs(var_x, tau)
    h_t_tau_expr = h_expr.subs(var_h, t - tau)

    local_dict = self._get_local_dict()
    t_vals = np.linspace(*self.horiz_range, num_points)
    y_vals = []

    # Case 1: Dirac in x(t)
    if x_tau_expr.has(sp.DiracDelta):
        y_expr = 0
        for term in x_tau_expr.as_ordered_terms():
            if term.has(sp.DiracDelta):
                args = term.args if term.func == sp.Mul else [term]
                scale = 1
                for a in args:
                    if a.func == sp.DiracDelta:
                        delta_arg = a.args[0]
                    else:
                        scale *= a
                shift = sp.solve(delta_arg, tau)
                if shift:
                    y_expr += scale * h_expr.subs(var_h, t - shift[0])

        # Extract impulses from y_expr
        impulse_locs = []
        impulse_areas = []
        for term in y_expr.as_ordered_terms():
            if term.has(sp.DiracDelta):
                args = term.args if term.func == sp.Mul else [term]
                area = 1
                shift = 0
                for a in args:
                    if a.func == sp.DiracDelta:
                        sol = sp.solve(a.args[0], t)
                        if sol:
                            shift = float(sol[0])
                    else:
                        area *= a
                impulse_locs.append(shift)
                impulse_areas.append(float(area))

        self._update_expression_and_func(y_expr, t)
        self.impulse_locs = impulse_locs
        self.impulse_areas = impulse_areas

    # Case 2: Dirac in h(t)
    elif h_t_tau_expr.has(sp.DiracDelta):
        y_expr = 0
        for term in h_t_tau_expr.as_ordered_terms():
            if term.has(sp.DiracDelta):
                args = term.args if term.func == sp.Mul else [term]
                scale = 1
                for a in args:
                    if a.func == sp.DiracDelta:
                        delta_arg = a.args[0]
                    else:
                        scale *= a
                shift = sp.solve(delta_arg, tau)
                if shift:
                    y_expr += scale * x_tau_expr.subs(tau, shift[0])

        impulse_locs = []
        impulse_areas = []
        for term in y_expr.as_ordered_terms():
            if term.has(sp.DiracDelta):
                args = term.args if term.func == sp.Mul else [term]
                area = 1
                shift = 0
                for a in args:
                    if a.func == sp.DiracDelta:
                        sol = sp.solve(a.args[0], t)
                        if sol:
                            shift = float(sol[0])
                    else:
                        area *= a
                impulse_locs.append(shift)
                impulse_areas.append(float(area))

        self._update_expression_and_func(y_expr, t)
        self.impulse_locs = impulse_locs
        self.impulse_areas = impulse_areas

    # Case 3: General convolution via numerical integration
    else:
        x_func_tau = sp.lambdify(tau, x_tau_expr, modules=["numpy", local_dict])

        def h_func_tau_shifted(tau_val, t_val):
            h_t_tau = h_t_tau_expr.subs(t, t_val)
            h_func = sp.lambdify(tau, h_t_tau, modules=["numpy", local_dict])
            return h_func(tau_val)

        support_x = self.horiz_range
        support_h = self.horiz_range

        for t_val in t_vals:
            a = max(support_x[0], t_val - support_h[1])
            b = min(support_x[1], t_val - support_h[0])
            if a >= b:
                y_vals.append(0)
                continue
            integrand = lambda tau_val: x_func_tau(tau_val) * h_func_tau_shifted(tau_val, t_val)
            try:
                val, _ = integrate.quad(integrand, a, b)
            except Exception:
                val = 0
            y_vals.append(val)

        self.func = lambda t_: np.interp(t_, t_vals, y_vals)
        self.impulse_locs = []
        self.impulse_areas = []
        self.expr = None

    # Final settings and plot
    self.t_vals = t_vals
    self.xlabel = "t"
    self.ylabel = r"y(t)"

    self._setup_figure()
    self.setup_axes()
    self.draw_function()
    self.draw_impulses()
    self.draw_ticks()
    self.draw_labels()
    self.show()

plot_convolution_steps(x_name, h_name, t_val, tau=None, t=None)

Plots four key signals involved in a convolution step at a fixed time t_val: x(tau), x(t-tau), h(tau), h(t-tau)., all in terms of τ.

This method is particularly useful for visualizing the time-reversed and shifted versions of the input signals used in the convolution integral.

Notes: - The horizontal axis is adjusted for time-reversed signals (e.g., t−τ), and tick labels are shifted accordingly. - Four separate plots are generated in sequence, with labels and axes automatically set.

Parameters:

Name Type Description Default
x_name str

Name of the signal x, previously defined with add_signal(...).

required
h_name str

Name of the signal h, previously defined with add_signal(...).

required
t_val float

The fixed time t at which the convolution step is evaluated.

required
tau Symbol or str

Symbol for the integration variable (default: 'tau').

None
t Symbol or str

Symbol for the time variable (default: 't').

None

Examples:

>>> sp = SignalPlotter()
>>> sp.add_signal("x(t)=sinc(t)")
>>> sp.add_signal("h(t)=sinc(t/2)")
>>> sp.plot_convolution_steps("x", "h", t_val=1)
Source code in signalblocks\SignalPlotter.py
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
def plot_convolution_steps(self, x_name, h_name, t_val, tau=None, t=None):
    """
    Plots four key signals involved in a convolution step at a fixed time `t_val`:
    x(tau), x(t-tau), h(tau), h(t-tau)., all in terms of τ.

    This method is particularly useful for visualizing the time-reversed and shifted
    versions of the input signals used in the convolution integral.

    Notes:
    - The horizontal axis is adjusted for time-reversed signals (e.g., t−τ),
    and tick labels are shifted accordingly.
    - Four separate plots are generated in sequence, with labels and axes automatically set.

    Args:
        x_name (str): Name of the signal x, previously defined with `add_signal(...)`.
        h_name (str): Name of the signal h, previously defined with `add_signal(...)`.
        t_val (float): The fixed time t at which the convolution step is evaluated.
        tau (sympy.Symbol or str, optional): Symbol for the integration variable (default: 'tau').
        t (sympy.Symbol or str, optional): Symbol for the time variable (default: 't').

    Examples:
        >>> sp = SignalPlotter()
        >>> sp.add_signal("x(t)=sinc(t)")
        >>> sp.add_signal("h(t)=sinc(t/2)")
        >>> sp.plot_convolution_steps("x", "h", t_val=1)
    """
    local_dict = self._get_local_dict()

    # Use default symbols if not provided
    if tau is None:
        tau = local_dict.get('tau')
    elif isinstance(tau, str):
        tau = sp.Symbol(tau)

    if t is None:
        t = local_dict.get('t')
    elif isinstance(t, str):
        t = sp.Symbol(t)

    # Evaluate x(τ) and h(τ) using their respective symbolic variable
    x_expr = self.signal_defs[x_name].subs(self.var_symbols[x_name], tau)
    h_expr = self.signal_defs[h_name].subs(self.var_symbols[h_name], tau)

    # Compute x(t−τ) and h(t−τ), and substitute t = t_val
    x_shift = x_expr.subs(tau, t - tau).subs(t, t_val)
    h_shift = h_expr.subs(tau, t - tau).subs(t, t_val)

    # Convert to LaTeX strings for labeling
    tau_str = sp.latex(tau)
    t_str = sp.latex(t)

    # Generate custom xticks and labels for the shifted signals
    xticks = self.init_xticks_arg
    if xticks == 'auto':
        xticks_shifted = [t_val]
        xtick_labels_shifted = [f"{t_str}"]
    elif isinstance(xticks, (list, tuple)):
        xticks_shifted = [t_val - v for v in xticks]
        xtick_labels_shifted = []
        for v in xticks:
            delta = - v
            if delta == 0:
                label = fr"{t_str}"
            elif delta > 0:
                label = fr"{t_str}+{delta}"
            else:
                label = fr"{t_str}{delta}"  # delta is already negative
            xtick_labels_shifted.append(label)
    else:
        xticks_shifted = None
        xtick_labels_shifted = None

    horiz_range = self.horiz_range
    # Compute reversed horizontal range for time-reversed signals
    horiz_range_shifted = t_val - np.array(horiz_range)[::-1]

    # Define all 4 signals to be plotted with labels and optional custom ticks
    items = [
        (x_expr, fr"{x_name}({tau_str})", None, None, horiz_range),
        (h_expr, fr"{h_name}({tau_str})", None, None, horiz_range),
        (x_shift, fr"{x_name}({t_str}-{tau_str})", xticks_shifted, xtick_labels_shifted, horiz_range_shifted),
        (h_shift, fr"{h_name}({t_str}-{tau_str})", xticks_shifted, xtick_labels_shifted, horiz_range_shifted),
    ]

    for expr, label, xticks_custom, xtick_labels_custom, horiz_range_custom in items:
        # Prepare expression and plot configuration
        self._update_expression_and_func(expr, tau)
        self.xlabel = fr"\{tau}"
        self.ylabel = label
        self.t_vals = np.linspace(*horiz_range_custom, self.num_points)

        self._setup_figure()
        self.setup_axes(horiz_range_custom)
        self.draw_function(horiz_range_custom)
        self.draw_impulses()
        self.draw_ticks(xticks=xticks_custom, xtick_labels=xtick_labels_custom)
        self.draw_labels()
        self.show()

plot_convolution_view(expr_str, t_val, label=None, tau=None, t=None)

Plots an intermediate signal in the convolution process, such as x(t−τ), h(τ+t), etc.

This method: - Substitutes the convolution variable t with a fixed value t_val in a symbolic expression. - Evaluates the resulting signal in terms of τ. - Optionally adjusts x-axis direction and labels if the expression has a form like (t − τ) or (t + τ). - Automatically handles periodic xtick reversal or shift based on convolution expression. - Renders the function using existing plot methods (function, impulses, ticks, etc.).

Parameters:

Name Type Description Default
expr_str str

A symbolic expression involving τ and t, e.g. "x(t - tau)" or "h(t + tau)". The base signal must already be defined with add_signal(...).

required
t_val float

The value of the time variable t at which the expression is evaluated.

required
label str

Custom y-axis label to display (default is derived from the expression).

None
tau Symbol or str

Symbol to use as integration variable (default: 'tau').

None
t Symbol or str

Symbol used in shifting (default: 't').

None

Raises:

Type Description
ValueError

If the base signal is not defined or the expression format is invalid.

Examples:

>>> sp = SignalPlotter(xticks=[-1, 0, 3], num_points=200, fraction_ticks=True)
>>> sp.add_signal("x(t)=exp(-2t)*u(t)")
>>> sp.add_signal("h(t)=u(t)")
>>> sp.plot_convolution_view("x(t - tau)", t_val=1)
>>> sp.plot_convolution_view("h(t + tau)", t_val=2, tau='lambda', t='omega')
Source code in signalblocks\SignalPlotter.py
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
def plot_convolution_view(self, expr_str, t_val, label=None, tau=None, t=None):
    """
    Plots an intermediate signal in the convolution process, such as x(t−τ), h(τ+t), etc.

    This method:
    - Substitutes the convolution variable t with a fixed value `t_val` in a symbolic expression.
    - Evaluates the resulting signal in terms of τ.
    - Optionally adjusts x-axis direction and labels if the expression has a form like (t − τ) or (t + τ).
    - Automatically handles periodic xtick reversal or shift based on convolution expression.
    - Renders the function using existing plot methods (function, impulses, ticks, etc.).

    Args:
        expr_str (str): A symbolic expression involving τ and t, e.g. "x(t - tau)" or "h(t + tau)".
                    The base signal must already be defined with `add_signal(...)`.
        t_val (float): The value of the time variable `t` at which the expression is evaluated.
        label (str, optional): Custom y-axis label to display (default is derived from the expression).
        tau (sympy.Symbol or str, optional): Symbol to use as integration variable (default: 'tau').
        t (sympy.Symbol or str, optional): Symbol used in shifting (default: 't').

    Raises:
        ValueError: If the base signal is not defined or the expression format is invalid.

    Examples:
        >>> sp = SignalPlotter(xticks=[-1, 0, 3], num_points=200, fraction_ticks=True)
        >>> sp.add_signal("x(t)=exp(-2t)*u(t)")
        >>> sp.add_signal("h(t)=u(t)")
        >>> sp.plot_convolution_view("x(t - tau)", t_val=1)
        >>> sp.plot_convolution_view("h(t + tau)", t_val=2, tau='lambda', t='omega')
    """
    import re
    local_dict = self._get_local_dict()

    # Define symbolic variables for τ and t
    if tau is None:
        tau = local_dict.get('tau')
    elif isinstance(tau, str):
        tau = sp.Symbol(tau)
    if t is None:
        t = local_dict.get('t')
    elif isinstance(t, str):
        t = sp.Symbol(t)

    local_dict.update({'tau': tau, 't': t, str(tau): tau, str(t): t})

    # Extract base signal name and ensure it's defined
    if "(" in expr_str:
        name = expr_str.split("(")[0].strip()
        if name not in self.signal_defs:
            raise ValueError(f"Signal '{name}' is not defined.")
        expr_base = self.signal_defs[name]
        var_base = self.var_symbols.get(name, t)
    else:
        raise ValueError("Invalid expression: expected something like 'x(t - tau)'.")

    # Parse expression and apply to base
    parsed_expr = parse_expr(expr_str.replace(name, "", 1), local_dict)
    expr = expr_base.subs(var_base, parsed_expr)

    # Analyze structure to adapt axis
    xticks = self.init_xticks_arg
    horiz_range = self.horiz_range
    xticks_custom = None
    xtick_labels_custom = None

    if isinstance(parsed_expr, sp.Expr):
        diff1 = parsed_expr - tau
        if diff1.has(t):
            diff2 = parsed_expr - t
            coef = diff2.coeff(tau)
            if coef == -1:
                # Case t - tau ⇒ reverse x-axis
                if xticks == 'auto':
                    xticks_custom = [t_val]
                    xtick_labels_custom = [sp.latex(t)]
                elif isinstance(xticks, (list, tuple)):
                    xticks_custom = [t_val - v for v in xticks][::-1]
                    xtick_labels_custom = [
                        f"{sp.latex(t)}" if v == 0 else f"{sp.latex(t)}{'-' if v > 0 else '+'}{abs(v)}"
                        for v in xticks
                    ][::-1]
                horiz_range = (t_val - np.array(self.horiz_range)[::-1]).tolist()
            elif coef == 1:
                # Case t + tau ⇒ shift axis
                if xticks == 'auto':
                    xticks_custom = [- t_val]
                    xtick_labels_custom = [sp.latex(t)]
                elif isinstance(xticks, (list, tuple)):
                    xticks_custom = [- t_val + v for v in xticks]
                    xtick_labels_custom = [
                        f"-{sp.latex(t)}" if v == 0 else f"-{sp.latex(t)}{'+' if v > 0 else '-'}{abs(v)}"
                        for v in xticks
                    ]
                horiz_range = (np.array(self.horiz_range) - t_val).tolist()

    # Evaluate the expression at t = t_val
    expr_evaluated = expr.subs(t, t_val)

    # Update expression and lambdified function
    self._update_expression_and_func(expr_evaluated, tau)

    # Axis labels
    self.xlabel = sp.latex(tau)
    tau_str = sp.latex(tau)
    t_str = sp.latex(t)
    self.ylabel = label if label else expr_str.replace("tau", tau_str).replace("t", t_str)

    # Discretize time
    self.t_vals = np.linspace(*horiz_range, self.num_points)

    # Prepare and render plot
    self._setup_figure()
    self.setup_axes(horiz_range)
    self.draw_function(horiz_range)
    self.draw_impulses()
    self.draw_ticks(xticks=xticks_custom, xtick_labels=xtick_labels_custom)
    self.draw_labels()
    self.show()

setup_axes(horiz_range=None)

Configures the plot axes: hides borders, sets limits, and draws arrow-like axes. This method is typically called after _prepare_plot() to finalize the plot appearance. This method is usually called internally from plot(), but can also be used manually.

This method: - Hides the default box (spines) around the plot. - Clears all default ticks. - Sets the horizontal and vertical limits based on the signal range. - Adds margin space around the plotted data to improve visual clarity. - Draws custom x- and y-axis arrows using annotate. - Calls tight_layout() to prevent label clipping.

Notes: - The horizontal axis includes a 20% margin on both sides. - The vertical axis includes 30% below and 60% above the data range. - The vertical range must be computed beforehand via _prepare_plot().

Parameters:

Name Type Description Default
horiz_range tuple

If provided, overrides the default horizontal range.

None

Examples:

>>> self._prepare_plot()
>>> self.setup_axes()
Source code in signalblocks\SignalPlotter.py
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
def setup_axes(self, horiz_range=None):
    """
    Configures the plot axes: hides borders, sets limits, and draws arrow-like axes.
    This method is typically called after `_prepare_plot()` to finalize the plot appearance.
    This method is usually called internally from `plot()`, but can also be used manually.

    This method:
    - Hides the default box (spines) around the plot.
    - Clears all default ticks.
    - Sets the horizontal and vertical limits based on the signal range.
    - Adds margin space around the plotted data to improve visual clarity.
    - Draws custom x- and y-axis arrows using `annotate`.
    - Calls `tight_layout()` to prevent label clipping.

    Notes:
    - The horizontal axis includes a 20% margin on both sides.
    - The vertical axis includes 30% below and 60% above the data range.
    - The vertical range must be computed beforehand via `_prepare_plot()`.

    Args:
        horiz_range (tuple, optional): If provided, overrides the default horizontal range.

    Examples:
        >>> self._prepare_plot()
        >>> self.setup_axes()
    """
    # Hide all axis spines (borders)
    for spine in self.ax.spines.values():
        spine.set_color('none')

    # Remove default ticks
    self.ax.set_xticks([])
    self.ax.set_yticks([])

    if horiz_range is None:
        horiz_range = self.horiz_range

    # Compute horizontal range and margin
    x0, x1 = horiz_range
    x_range = x1 - x0
    # You can adjust this value if needed
    x_margin = 0.2 * x_range

    # Use vertical range computed in _prepare_plot
    y_min, y_max = self.y_min, self.y_max
    y_range = y_max - y_min

    # Add a 30% margin below and 60% margin above the signal range
    if y_range <= 0:
        # In degenerate cases, ensure a minimum visible height
        y_margin = 1.0
    else:
        y_margin = 0.3 * y_range

    self.ax.set_xlim(horiz_range[0] - x_margin, horiz_range[1] + x_margin)
    self.ax.set_ylim(self.y_min - y_margin, self.y_max + 1.6 * y_margin)

    # Draw x-axis arrow
    self.ax.annotate('', xy=(self.ax.get_xlim()[1], 0), xytext=(self.ax.get_xlim()[0], 0),
                     arrowprops=dict(arrowstyle='-|>', linewidth=1.5, color='black',
                                     mutation_scale=16, mutation_aspect=0.8, fc='black'))

    # Draw x-axis arrow
    self.ax.annotate('', xy=(0, self.ax.get_ylim()[1]), xytext=(0, self.ax.get_ylim()[0]),
                     arrowprops=dict(arrowstyle='-|>', linewidth=1.5, color='black',
                                     mutation_scale=12, mutation_aspect=2, fc='black'))

    # Prevent labels from being clipped
    self.fig.tight_layout()

show()

Displays or saves the final plot, depending on configuration. This method is typically called after draw_labels(). This method is usually called internally from plot(), but can also be used manually.

This method: - Disables the background grid. - Applies tight layout to reduce unnecessary whitespace. - If self.save_path is set, saves the figure to the given file (PNG, PDF, etc.). - If self.show_plot is True, opens a plot window (interactive view). - Finally, closes the figure to free up memory (especially important in batch plotting).

Notes: - self.save_path and self.show_plot are set in the constructor. - If both are enabled, the plot is shown and saved. - The output file format is inferred from the file extension.

Examples:

>>> self.show()  # Typically called at the end of plot()
Source code in signalblocks\SignalPlotter.py
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
def show(self):
    """
    Displays or saves the final plot, depending on configuration.
    This method is typically called after `draw_labels()`.
    This method is usually called internally from `plot()`, but can also be used manually.

    This method:
    - Disables the background grid.
    - Applies tight layout to reduce unnecessary whitespace.
    - If `self.save_path` is set, saves the figure to the given file (PNG, PDF, etc.).
    - If `self.show_plot` is True, opens a plot window (interactive view).
    - Finally, closes the figure to free up memory (especially important in batch plotting).

    Notes:
    - `self.save_path` and `self.show_plot` are set in the constructor.
    - If both are enabled, the plot is shown and saved.
    - The output file format is inferred from the file extension.

    Examples:
        >>> self.show()  # Typically called at the end of plot()
    """
    self.ax.grid(False)
    plt.tight_layout()
    if self.save_path:
        self.fig.savefig(self.save_path, dpi=300, bbox_inches='tight')
    if self.show_plot:
        plt.show()
    plt.close(self.fig)

ComplexPlane

ComplexPlane

Helper class to visualize poles, zeros and regions of convergence (ROC) on the complex plane of the Z Transform. It supports both cartesian and polar input for coordinates.

Examples:

>>> cp = ComplexPlane()
>>> cp.draw_poles_and_zeros(poles=[0.5+0.5j, (0.7, np.pi/4)], zeros=[-0.5+0j, (1, np.pi)])
>>> cp.draw_ROC("|z|>0.7")
>>> cp.draw_unit_circle()
>>> cp.show()
Source code in signalblocks\ComplexPlane.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
class ComplexPlane:
    """
    Helper class to visualize poles, zeros and regions of convergence (ROC) 
    on the complex plane of the Z Transform. It supports both cartesian and polar input for coordinates.

    Examples:
        >>> cp = ComplexPlane()
        >>> cp.draw_poles_and_zeros(poles=[0.5+0.5j, (0.7, np.pi/4)], zeros=[-0.5+0j, (1, np.pi)])
        >>> cp.draw_ROC("|z|>0.7")
        >>> cp.draw_unit_circle()
        >>> cp.show()
    """
    def __init__(self, figsize=(6, 6), xlim=(-1.5, 1.5), ylim=(-1.5, 1.5), facecolor='white', fontsize=18):

        self.fig, self.ax = plt.subplots(figsize=figsize, facecolor='white')

        self.fontsize = fontsize
        self.xlim = xlim
        self.ylim = ylim

        self.poles = []
        self.zeros = []

    # --- Helper functions ---

    def __get_bbox__(self):
        return self.ax.dataLim

    # --- Calculus functions ---

    def max_pole_modulus(self, poles=None):
        """
        Compute maximum modulus of poles.

        Args:
            poles (list, optional): List of poles (complex or (r, θ) tuples). Defaults to self.poles.

        Returns:
            (float): Maximum modulus.

        Examples:
            >>> cp.max_pole_modulus()
        """

        if poles is None:
            poles = self.poles
        if not isinstance(poles, list):
            raise ValueError("poles must be a list of complex numbers or (magnitude, phase) tuples.")

        moduli = []
        for p in poles:
            if isinstance(p, tuple):  # Polar form
                r, theta = p
                z = r * np.exp(1j * theta)
            else:
                z = p  # Cartesian form (complex number)
            moduli.append(abs(z))
        return max(moduli) if moduli else None

    def min_pole_modulus(self, poles=None):
        """
        Compute minimum modulus of poles.

        Args:
            poles (list, optional): List of poles (complex or (r, θ) tuples). Defaults to self.poles.

        Returns:
            (float): Minimum modulus.

        Examples:
            >>> cp.min_pole_modulus()
        """

        if poles is None:
            poles = self.poles
        if not isinstance(poles, list):
            raise ValueError("poles must be a list of complex numbers or (magnitude, phase) tuples.")

        moduli = []
        for p in poles:
            if isinstance(p, tuple):  # Polar form
                r, theta = p
                z = r * np.exp(1j * theta)
            else:
                z = p  # Cartesian form (complex number)
            moduli.append(abs(z))
        return min(moduli) if moduli else None

    # --- Drawing functions ---

    def _process_points(self, points):
        """Convert all points to complex numbers, accepting polar or cartesian input."""
        result = []
        for p in points:
            if isinstance(p, complex):
                result.append(p)
            elif (isinstance(p, tuple) or isinstance(p, list)) and len(p) == 2:
                r, theta = p
                z = r * np.exp(1j * theta)
                result.append(z)
            else:
                raise ValueError(f"Invalid format: {p}")
        return result

    def _round_complex(self, z, decimals=6):
        """Round complex numbers to avoid floating-point noise."""
        return complex(round(z.real, decimals), round(z.imag, decimals))

    def draw_poles_and_zeros(self, poles=None, zeros=None):
        """
        Draw poles (red crosses) and zeros (blue circles).

        Args:
            poles (list, optional): Complex or polar coordinates.
            zeros (list, optional): Complex or polar coordinates.

        Examples:
            >>> cp.draw_poles_and_zeros(poles=[0.5+0.5j], zeros=[-0.5])
        """
        if poles:
            new_poles = self._process_points(poles)
            self.poles.extend(new_poles)
        if zeros:
            new_zeros = self._process_points(zeros)
            self.zeros.extend(new_zeros)

        # Count multiplicities
        poles_counter = Counter(self._round_complex(z) for z in self.poles)
        zeros_counter = Counter(self._round_complex(z) for z in self.zeros)

        # Plot poles
        for pole, count in poles_counter.items():
            self.ax.scatter(pole.real, pole.imag,
                       marker='x',
                       color='red',
                       s=100 + 20 * (count - 1),
                       alpha=min(0.4 + 0.15 * count, 1.0),
                       label=f'Pole x{count}' if count > 1 else 'Pole',
                       linewidths=3,
                       zorder=10)

        # Plot zeros
        for zero, count in zeros_counter.items():
            self.ax.scatter(zero.real, zero.imag,
                       marker='o',
                       edgecolors='blue',
                       facecolors='none',
                       linewidths=2,
                       s=100 + 20 * (count - 1),
                       alpha=min(0.4 + 0.15 * count, 1.0),
                       label=f'Zero x{count}' if count > 1 else 'Zero',
                       zorder=10)

    def draw_ROC(self, condition, color='orange', alpha=0.3, label="ROC"):
        """
        Draw region of convergence (ROC).

        Args:
            condition (str): One of "|z|<a", "|z|>a", "a<|z|<b".
            color (str, optional): ROC fill color.
            alpha (float, optional): Transparency.
            label (str, optional): Label for legend.

        Examples:
            >>> cp.draw_ROC("|z|>0.7")
        """
        edge_color = color

        R_max = 2.5 * max(abs(self.xlim[0]), abs(self.xlim[1]), abs(self.ylim[0]), abs(self.ylim[1]))

        condition = condition.replace(" ", "")
        if condition.startswith("|z|<"):
            a = eval(condition[4:])
            region_type = 'less'
        elif condition.startswith("|z|>"):
            a = eval(condition[4:])
            region_type = 'greater'
        elif '<|z|<' in condition:
            a_str, b_str = condition.split('<|z|<')
            a = eval(a_str)
            b = eval(b_str)
            region_type = 'between'
        else:
            raise ValueError("Invalid condition. Use formats like '|z|<a', '|z|>a' or 'a<|z|<b'.")

        # === Draw ROC ===
        if region_type == 'less':
            patch = Circle((0, 0), a, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0, label=label)
            self.ax.add_patch(patch)
        elif region_type == 'greater':
            patch_outer = Circle((0, 0), R_max, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0)
            patch_inner = Circle((0, 0), a, facecolor='white', edgecolor='none', zorder=0)
            patch_border = Circle((0, 0), a, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=2, label=label)
            self.ax.add_patch(patch_outer)
            self.ax.add_patch(patch_inner)
            self.ax.add_patch(patch_border)
        elif region_type == 'between':
            patch_outer = Circle((0, 0), b, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0)
            patch_inner = Circle((0, 0), a, facecolor='white', edgecolor='none', zorder=0)
            patch_border_outer = Circle((0, 0), b, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=1)
            patch_border_inner = Circle((0, 0), a, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=1, label=label)
            self.ax.add_patch(patch_outer)
            self.ax.add_patch(patch_inner)
            self.ax.add_patch(patch_border_outer)
            self.ax.add_patch(patch_border_inner)

    def draw_radial_guides(self, labels, radii, angles=None, circles=None,
                        avoid_overlap=True, delta_angle=np.pi/24, offset_angle=np.pi/30, color='blue'):
        """
        Draws radial lines from origin with optional labels and dashed circles.
        Avoids placing radios exactly at 0 and pi by offsetting them.

        Args:
            labels (list): Labels for each radial.
            radii (list): Radii.
            angles (list, optional): Angles (if None, auto-generated).
            circles (list, optional): If True, draw dashed circle at each radius.
            avoid_overlap (bool): Avoid conflict with poles/zeros.
            delta_angle (float): Angular increment if searching free angles.
            offset_angle (float): Offset from 0 and π.
            color (str): Color of lines.

        Examples:
            >>> cp.draw_radial_guides(labels=["a", "b"], radii=[0.5, 1.2])
        """
        ax = self.ax

        if circles is None:
            circles = [False] * len(radii)

        n = len(radii)

        if angles is None:
            angles = []

            # Collect forbidden angles if necessary
            forbidden = set()
            if avoid_overlap:
                def extract_angles(items):
                    result = []
                    for item in items:
                        if isinstance(item, tuple):
                            r, theta = item
                        else:
                            theta = np.angle(item)
                        result.append(round(theta % (2 * np.pi), 4))
                    return result

                forbidden.update(extract_angles(getattr(self, 'poles', [])))
                forbidden.update(extract_angles(getattr(self, 'zeros', [])))

            # Prepare candidate base angles avoiding exactly 0 and π
            base_angles = [offset_angle, np.pi - offset_angle,
                        np.pi + offset_angle, 2 * np.pi - offset_angle]

            # Fill angles list trying these first, then try offsets further away if needed
            i = 0
            while len(angles) < n:
                for base in base_angles:
                    candidate = (base + i * delta_angle) % (2 * np.pi)
                    if round(candidate, 4) not in forbidden and candidate not in angles:
                        angles.append(candidate)
                        if len(angles) == n:
                            break
                i += 1

            # If still not enough angles (rare), fill uniformly excluding forbidden
            while len(angles) < n:
                candidate = (angles[-1] + delta_angle) % (2 * np.pi)
                if round(candidate, 4) not in forbidden and candidate not in angles:
                    angles.append(candidate)

        # === Draw radial guides ===
        for label, r, ang, circ in zip(labels, radii, angles, circles):
            x = r * np.cos(ang)
            y = r * np.sin(ang)

            ax.plot([0, x], [0, y], color=color, linewidth=2, zorder=8)

            if label:
                angle_deg = np.degrees(ang)
                if 90 < angle_deg < 270:
                    angle_deg -= 180
                elif angle_deg > 270:
                    angle_deg -= 360

                ax.text(x * 0.5, y * 0.5 + 0.05, f"${label}$", fontsize=self.fontsize,
                        ha='center', va='bottom', rotation=angle_deg,
                        rotation_mode='anchor', color=color, zorder=10)

            if circ:
                ax.add_patch(Circle((0, 0), r, edgecolor=color, facecolor='none',
                                    linestyle='--', linewidth=1.2, zorder=7))

    def draw_unit_circle(self, linestyle='--', color='black', linewidth=1.2):
        """
        Draw unit circle.

        Args:
            linestyle (str): Line style.
            color (str): Color.
            linewidth (float): Line width.

        Examples:
            >>> cp.draw_unit_circle()
        """
        ax = self.ax

        pos_x = 1 + self.xlim[1] / 25
        pos_y = self.ylim[1] / 25
        ax.text(pos_x, pos_y, f"${1}$", fontsize=12,
                ha='center', va='bottom', color=color,
                rotation_mode='anchor', zorder=10)

        ax.add_patch(Circle((0, 0), 1, edgecolor=color, facecolor='none',
                            linestyle=linestyle, linewidth=linewidth, label=f'$|z|=1$', zorder=7))

    def label_positions(self, positions, labels, offset=0.08):
        """
        Add text labels at given positions.

        Args:
            positions (list): Complex or polar coordinates.
            labels (list): Text labels.
            offset (float): Vertical offset.

        Examples:
            >>> cp.label_positions([0.5+0.5j], ["A"])
        """
        points_cartesian = self._process_points(positions)

        for pos, label in zip(points_cartesian, labels):
            x, y = pos.real, pos.imag

            # Small black circle
            self.ax.plot(x, y, 'o', color='black', markersize=3, zorder=9)
            print(label)
            # Label text slightly above the marker
            self.ax.text(x, y + offset, f"${label}$", fontsize=12, ha='center', va='bottom', zorder=10)

    # === Show and save ===

    def show(self, savepath=None):
        """
        Display or save the figure.

        Args:
            savepath (str, optional): Path to save figure as file. If None, shows the plot.

        Examples:
            >>> cp.show()
            >>> cp.show("myplot.png")
        """
        self.ax.set_aspect('equal')
        self.ax.set_xlim(self.xlim)
        self.ax.set_ylim(self.ylim)

        # Remove numeric ticks
        self.ax.set_xticks([])
        self.ax.set_yticks([])

        # Remove frame
        for spine in ['top', 'right', 'bottom', 'left']:
            self.ax.spines[spine].set_visible(False)

        # === Axis ===
        self.ax.axhline(0, color='black', linewidth=1, zorder=15)
        self.ax.axvline(0, color='black', linewidth=1, zorder=15)

        # # Axis lables
        # self.ax.axis('off')
        # self.ax.text(self.xlim[1], 0.05, r'$\Re\{z\}$', 
        #              fontsize=self.fontsize, ha='right', va='bottom')
        # self.ax.text(0.05, self.ylim[1], r'$\Im\{z\}$', 
        #              fontsize=self.fontsize, ha='left', va='top')

        # Axis labels (just outside the plot area)
        x_label_pos = self.xlim[1] + 0.03 * (self.xlim[1] - self.xlim[0])
        y_label_pos = self.ylim[1] + 0.03 * (self.ylim[1] - self.ylim[0])

        self.ax.text(x_label_pos, 0,
                    r'$\Re\{z\}$',
                    fontsize=self.fontsize,
                    ha='left', va='center',
                    zorder=15)

        self.ax.text(0, y_label_pos,
                    r'$\Im\{z\}$',
                    fontsize=self.fontsize,
                    ha='center', va='bottom',
                    zorder=15)

        # Deduplicate legend
        handles, labels = self.ax.get_legend_handles_labels()
        seen = set()
        unique = [(h, l) for h, l in zip(handles, labels) if l not in seen and not seen.add(l)]
        if unique:
            self.ax.legend(*zip(*unique), loc='upper left', bbox_to_anchor=(1.05, 1))

        if savepath:
            self.fig.savefig(savepath, bbox_inches='tight', dpi=self.fig.dpi, transparent=False, facecolor='white')
            print(f"Saved in: {savepath}")
        else:
            plt.show()

draw_ROC(condition, color='orange', alpha=0.3, label='ROC')

Draw region of convergence (ROC).

Parameters:

Name Type Description Default
condition str

One of "|z|a", "a<|z|<b".

required
color str

ROC fill color.

'orange'
alpha float

Transparency.

0.3
label str

Label for legend.

'ROC'

Examples:

>>> cp.draw_ROC("|z|>0.7")
Source code in signalblocks\ComplexPlane.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def draw_ROC(self, condition, color='orange', alpha=0.3, label="ROC"):
    """
    Draw region of convergence (ROC).

    Args:
        condition (str): One of "|z|<a", "|z|>a", "a<|z|<b".
        color (str, optional): ROC fill color.
        alpha (float, optional): Transparency.
        label (str, optional): Label for legend.

    Examples:
        >>> cp.draw_ROC("|z|>0.7")
    """
    edge_color = color

    R_max = 2.5 * max(abs(self.xlim[0]), abs(self.xlim[1]), abs(self.ylim[0]), abs(self.ylim[1]))

    condition = condition.replace(" ", "")
    if condition.startswith("|z|<"):
        a = eval(condition[4:])
        region_type = 'less'
    elif condition.startswith("|z|>"):
        a = eval(condition[4:])
        region_type = 'greater'
    elif '<|z|<' in condition:
        a_str, b_str = condition.split('<|z|<')
        a = eval(a_str)
        b = eval(b_str)
        region_type = 'between'
    else:
        raise ValueError("Invalid condition. Use formats like '|z|<a', '|z|>a' or 'a<|z|<b'.")

    # === Draw ROC ===
    if region_type == 'less':
        patch = Circle((0, 0), a, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0, label=label)
        self.ax.add_patch(patch)
    elif region_type == 'greater':
        patch_outer = Circle((0, 0), R_max, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0)
        patch_inner = Circle((0, 0), a, facecolor='white', edgecolor='none', zorder=0)
        patch_border = Circle((0, 0), a, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=2, label=label)
        self.ax.add_patch(patch_outer)
        self.ax.add_patch(patch_inner)
        self.ax.add_patch(patch_border)
    elif region_type == 'between':
        patch_outer = Circle((0, 0), b, facecolor=color, alpha=alpha, edgecolor=edge_color, linewidth=2, zorder=0)
        patch_inner = Circle((0, 0), a, facecolor='white', edgecolor='none', zorder=0)
        patch_border_outer = Circle((0, 0), b, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=1)
        patch_border_inner = Circle((0, 0), a, facecolor='none', edgecolor=edge_color, linewidth=2, zorder=1, label=label)
        self.ax.add_patch(patch_outer)
        self.ax.add_patch(patch_inner)
        self.ax.add_patch(patch_border_outer)
        self.ax.add_patch(patch_border_inner)

draw_poles_and_zeros(poles=None, zeros=None)

Draw poles (red crosses) and zeros (blue circles).

Parameters:

Name Type Description Default
poles list

Complex or polar coordinates.

None
zeros list

Complex or polar coordinates.

None

Examples:

>>> cp.draw_poles_and_zeros(poles=[0.5+0.5j], zeros=[-0.5])
Source code in signalblocks\ComplexPlane.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def draw_poles_and_zeros(self, poles=None, zeros=None):
    """
    Draw poles (red crosses) and zeros (blue circles).

    Args:
        poles (list, optional): Complex or polar coordinates.
        zeros (list, optional): Complex or polar coordinates.

    Examples:
        >>> cp.draw_poles_and_zeros(poles=[0.5+0.5j], zeros=[-0.5])
    """
    if poles:
        new_poles = self._process_points(poles)
        self.poles.extend(new_poles)
    if zeros:
        new_zeros = self._process_points(zeros)
        self.zeros.extend(new_zeros)

    # Count multiplicities
    poles_counter = Counter(self._round_complex(z) for z in self.poles)
    zeros_counter = Counter(self._round_complex(z) for z in self.zeros)

    # Plot poles
    for pole, count in poles_counter.items():
        self.ax.scatter(pole.real, pole.imag,
                   marker='x',
                   color='red',
                   s=100 + 20 * (count - 1),
                   alpha=min(0.4 + 0.15 * count, 1.0),
                   label=f'Pole x{count}' if count > 1 else 'Pole',
                   linewidths=3,
                   zorder=10)

    # Plot zeros
    for zero, count in zeros_counter.items():
        self.ax.scatter(zero.real, zero.imag,
                   marker='o',
                   edgecolors='blue',
                   facecolors='none',
                   linewidths=2,
                   s=100 + 20 * (count - 1),
                   alpha=min(0.4 + 0.15 * count, 1.0),
                   label=f'Zero x{count}' if count > 1 else 'Zero',
                   zorder=10)

draw_radial_guides(labels, radii, angles=None, circles=None, avoid_overlap=True, delta_angle=np.pi / 24, offset_angle=np.pi / 30, color='blue')

Draws radial lines from origin with optional labels and dashed circles. Avoids placing radios exactly at 0 and pi by offsetting them.

Parameters:

Name Type Description Default
labels list

Labels for each radial.

required
radii list

Radii.

required
angles list

Angles (if None, auto-generated).

None
circles list

If True, draw dashed circle at each radius.

None
avoid_overlap bool

Avoid conflict with poles/zeros.

True
delta_angle float

Angular increment if searching free angles.

pi / 24
offset_angle float

Offset from 0 and π.

pi / 30
color str

Color of lines.

'blue'

Examples:

>>> cp.draw_radial_guides(labels=["a", "b"], radii=[0.5, 1.2])
Source code in signalblocks\ComplexPlane.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def draw_radial_guides(self, labels, radii, angles=None, circles=None,
                    avoid_overlap=True, delta_angle=np.pi/24, offset_angle=np.pi/30, color='blue'):
    """
    Draws radial lines from origin with optional labels and dashed circles.
    Avoids placing radios exactly at 0 and pi by offsetting them.

    Args:
        labels (list): Labels for each radial.
        radii (list): Radii.
        angles (list, optional): Angles (if None, auto-generated).
        circles (list, optional): If True, draw dashed circle at each radius.
        avoid_overlap (bool): Avoid conflict with poles/zeros.
        delta_angle (float): Angular increment if searching free angles.
        offset_angle (float): Offset from 0 and π.
        color (str): Color of lines.

    Examples:
        >>> cp.draw_radial_guides(labels=["a", "b"], radii=[0.5, 1.2])
    """
    ax = self.ax

    if circles is None:
        circles = [False] * len(radii)

    n = len(radii)

    if angles is None:
        angles = []

        # Collect forbidden angles if necessary
        forbidden = set()
        if avoid_overlap:
            def extract_angles(items):
                result = []
                for item in items:
                    if isinstance(item, tuple):
                        r, theta = item
                    else:
                        theta = np.angle(item)
                    result.append(round(theta % (2 * np.pi), 4))
                return result

            forbidden.update(extract_angles(getattr(self, 'poles', [])))
            forbidden.update(extract_angles(getattr(self, 'zeros', [])))

        # Prepare candidate base angles avoiding exactly 0 and π
        base_angles = [offset_angle, np.pi - offset_angle,
                    np.pi + offset_angle, 2 * np.pi - offset_angle]

        # Fill angles list trying these first, then try offsets further away if needed
        i = 0
        while len(angles) < n:
            for base in base_angles:
                candidate = (base + i * delta_angle) % (2 * np.pi)
                if round(candidate, 4) not in forbidden and candidate not in angles:
                    angles.append(candidate)
                    if len(angles) == n:
                        break
            i += 1

        # If still not enough angles (rare), fill uniformly excluding forbidden
        while len(angles) < n:
            candidate = (angles[-1] + delta_angle) % (2 * np.pi)
            if round(candidate, 4) not in forbidden and candidate not in angles:
                angles.append(candidate)

    # === Draw radial guides ===
    for label, r, ang, circ in zip(labels, radii, angles, circles):
        x = r * np.cos(ang)
        y = r * np.sin(ang)

        ax.plot([0, x], [0, y], color=color, linewidth=2, zorder=8)

        if label:
            angle_deg = np.degrees(ang)
            if 90 < angle_deg < 270:
                angle_deg -= 180
            elif angle_deg > 270:
                angle_deg -= 360

            ax.text(x * 0.5, y * 0.5 + 0.05, f"${label}$", fontsize=self.fontsize,
                    ha='center', va='bottom', rotation=angle_deg,
                    rotation_mode='anchor', color=color, zorder=10)

        if circ:
            ax.add_patch(Circle((0, 0), r, edgecolor=color, facecolor='none',
                                linestyle='--', linewidth=1.2, zorder=7))

draw_unit_circle(linestyle='--', color='black', linewidth=1.2)

Draw unit circle.

Parameters:

Name Type Description Default
linestyle str

Line style.

'--'
color str

Color.

'black'
linewidth float

Line width.

1.2

Examples:

>>> cp.draw_unit_circle()
Source code in signalblocks\ComplexPlane.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def draw_unit_circle(self, linestyle='--', color='black', linewidth=1.2):
    """
    Draw unit circle.

    Args:
        linestyle (str): Line style.
        color (str): Color.
        linewidth (float): Line width.

    Examples:
        >>> cp.draw_unit_circle()
    """
    ax = self.ax

    pos_x = 1 + self.xlim[1] / 25
    pos_y = self.ylim[1] / 25
    ax.text(pos_x, pos_y, f"${1}$", fontsize=12,
            ha='center', va='bottom', color=color,
            rotation_mode='anchor', zorder=10)

    ax.add_patch(Circle((0, 0), 1, edgecolor=color, facecolor='none',
                        linestyle=linestyle, linewidth=linewidth, label=f'$|z|=1$', zorder=7))

label_positions(positions, labels, offset=0.08)

Add text labels at given positions.

Parameters:

Name Type Description Default
positions list

Complex or polar coordinates.

required
labels list

Text labels.

required
offset float

Vertical offset.

0.08

Examples:

>>> cp.label_positions([0.5+0.5j], ["A"])
Source code in signalblocks\ComplexPlane.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def label_positions(self, positions, labels, offset=0.08):
    """
    Add text labels at given positions.

    Args:
        positions (list): Complex or polar coordinates.
        labels (list): Text labels.
        offset (float): Vertical offset.

    Examples:
        >>> cp.label_positions([0.5+0.5j], ["A"])
    """
    points_cartesian = self._process_points(positions)

    for pos, label in zip(points_cartesian, labels):
        x, y = pos.real, pos.imag

        # Small black circle
        self.ax.plot(x, y, 'o', color='black', markersize=3, zorder=9)
        print(label)
        # Label text slightly above the marker
        self.ax.text(x, y + offset, f"${label}$", fontsize=12, ha='center', va='bottom', zorder=10)

max_pole_modulus(poles=None)

Compute maximum modulus of poles.

Parameters:

Name Type Description Default
poles list

List of poles (complex or (r, θ) tuples). Defaults to self.poles.

None

Returns:

Type Description
float

Maximum modulus.

Examples:

>>> cp.max_pole_modulus()
Source code in signalblocks\ComplexPlane.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def max_pole_modulus(self, poles=None):
    """
    Compute maximum modulus of poles.

    Args:
        poles (list, optional): List of poles (complex or (r, θ) tuples). Defaults to self.poles.

    Returns:
        (float): Maximum modulus.

    Examples:
        >>> cp.max_pole_modulus()
    """

    if poles is None:
        poles = self.poles
    if not isinstance(poles, list):
        raise ValueError("poles must be a list of complex numbers or (magnitude, phase) tuples.")

    moduli = []
    for p in poles:
        if isinstance(p, tuple):  # Polar form
            r, theta = p
            z = r * np.exp(1j * theta)
        else:
            z = p  # Cartesian form (complex number)
        moduli.append(abs(z))
    return max(moduli) if moduli else None

min_pole_modulus(poles=None)

Compute minimum modulus of poles.

Parameters:

Name Type Description Default
poles list

List of poles (complex or (r, θ) tuples). Defaults to self.poles.

None

Returns:

Type Description
float

Minimum modulus.

Examples:

>>> cp.min_pole_modulus()
Source code in signalblocks\ComplexPlane.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def min_pole_modulus(self, poles=None):
    """
    Compute minimum modulus of poles.

    Args:
        poles (list, optional): List of poles (complex or (r, θ) tuples). Defaults to self.poles.

    Returns:
        (float): Minimum modulus.

    Examples:
        >>> cp.min_pole_modulus()
    """

    if poles is None:
        poles = self.poles
    if not isinstance(poles, list):
        raise ValueError("poles must be a list of complex numbers or (magnitude, phase) tuples.")

    moduli = []
    for p in poles:
        if isinstance(p, tuple):  # Polar form
            r, theta = p
            z = r * np.exp(1j * theta)
        else:
            z = p  # Cartesian form (complex number)
        moduli.append(abs(z))
    return min(moduli) if moduli else None

show(savepath=None)

Display or save the figure.

Parameters:

Name Type Description Default
savepath str

Path to save figure as file. If None, shows the plot.

None

Examples:

>>> cp.show()
>>> cp.show("myplot.png")
Source code in signalblocks\ComplexPlane.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def show(self, savepath=None):
    """
    Display or save the figure.

    Args:
        savepath (str, optional): Path to save figure as file. If None, shows the plot.

    Examples:
        >>> cp.show()
        >>> cp.show("myplot.png")
    """
    self.ax.set_aspect('equal')
    self.ax.set_xlim(self.xlim)
    self.ax.set_ylim(self.ylim)

    # Remove numeric ticks
    self.ax.set_xticks([])
    self.ax.set_yticks([])

    # Remove frame
    for spine in ['top', 'right', 'bottom', 'left']:
        self.ax.spines[spine].set_visible(False)

    # === Axis ===
    self.ax.axhline(0, color='black', linewidth=1, zorder=15)
    self.ax.axvline(0, color='black', linewidth=1, zorder=15)

    # # Axis lables
    # self.ax.axis('off')
    # self.ax.text(self.xlim[1], 0.05, r'$\Re\{z\}$', 
    #              fontsize=self.fontsize, ha='right', va='bottom')
    # self.ax.text(0.05, self.ylim[1], r'$\Im\{z\}$', 
    #              fontsize=self.fontsize, ha='left', va='top')

    # Axis labels (just outside the plot area)
    x_label_pos = self.xlim[1] + 0.03 * (self.xlim[1] - self.xlim[0])
    y_label_pos = self.ylim[1] + 0.03 * (self.ylim[1] - self.ylim[0])

    self.ax.text(x_label_pos, 0,
                r'$\Re\{z\}$',
                fontsize=self.fontsize,
                ha='left', va='center',
                zorder=15)

    self.ax.text(0, y_label_pos,
                r'$\Im\{z\}$',
                fontsize=self.fontsize,
                ha='center', va='bottom',
                zorder=15)

    # Deduplicate legend
    handles, labels = self.ax.get_legend_handles_labels()
    seen = set()
    unique = [(h, l) for h, l in zip(handles, labels) if l not in seen and not seen.add(l)]
    if unique:
        self.ax.legend(*zip(*unique), loc='upper left', bbox_to_anchor=(1.05, 1))

    if savepath:
        self.fig.savefig(savepath, bbox_inches='tight', dpi=self.fig.dpi, transparent=False, facecolor='white')
        print(f"Saved in: {savepath}")
    else:
        plt.show()