-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfilter_design.py
More file actions
1884 lines (1609 loc) · 81.7 KB
/
filter_design.py
File metadata and controls
1884 lines (1609 loc) · 81.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
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
"""
filter_toolbox/filter_design.py
================================
Backend computation module.
STUDENT ENTRY POINTS
--------------------
Each function marked with ── STUDENT CODE ── is a stub that must be
implemented by the student team. The GUI calls these functions via the
callbacks in mainwindow.py; the signatures and return types MUST be kept
exactly as documented.
Dependencies (install via pip):
numpy, scipy, matplotlib, pyqtgraph, PySpice
"""
from __future__ import annotations
import numpy as np
import scipy.signal as sps
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
from itertools import groupby
# ──────────────────────────────────────────────────────────────────────────────
# Enumerations (mirrors the GUI combo-box indices)
# ──────────────────────────────────────────────────────────────────────────────
class Approximation(Enum):
BUTTERWORTH = 0
CHEBYSHEV_I = 1
CHEBYSHEV_II = 2
ELLIPTIC = 3
class FilterType(Enum):
LOWPASS = 0
HIGHPASS = 1
BANDPASS = 2
BANDSTOP = 3
class Topology(Enum):
SALLEN_KEY = 0
TOW_THOMAS = 1 # UAF42
DELIYANNIS = 2
class ESeries(Enum):
E6 = 6
E12 = 12
E24 = 24
E48 = 48
E96 = 96
EXACT = -1
# ──────────────────────────────────────────────────────────────────────────────
# Data containers
# ──────────────────────────────────────────────────────────────────────────────
@dataclass
class FilterSpec:
"""Filter specification entered by the user. All frequencies in Hz."""
approximation : Approximation = Approximation.BUTTERWORTH
filter_type : FilterType = FilterType.LOWPASS
fp : float = 1000.0 # passband edge Hz (lower edge for BP/BS)
fs : float = 2000.0 # stopband edge Hz (lower edge for BP/BS)
fp2 : float = 3000.0 # upper passband edge Hz (BP/BS only)
fs2 : float = 4000.0 # upper stopband edge Hz (BP/BS only)
a_p : float = 3.0 # max passband attenuation (dB)
a_s : float = 40.0 # min stopband attenuation (dB)
@property
def omega_p(self) -> float:
"""Passband edge in rad/s."""
return 2 * np.pi * self.fp
@property
def omega_s(self) -> float:
"""Stopband edge in rad/s."""
return 2 * np.pi * self.fs
@property
def omega_p2(self) -> float:
"""Upper passband edge in rad/s (BP/BS only)."""
return 2 * np.pi * self.fp2
@property
def omega_s2(self) -> float:
"""Upper stopband edge in rad/s (BP/BS only)."""
return 2 * np.pi * self.fs2
@property
def ripple_eps(self) -> float:
"""Passband ripple ε derived from Ap: ε = sqrt(10^(Ap/10) - 1)."""
return np.sqrt(10 ** (self.a_p / 10) - 1)
class SectionType(Enum):
"""
Classification of a single biquad / first-order section.
Determined by inspecting which numerator coefficients are
significant (above a small threshold relative to the leading term).
First-order sections
--------------------
LOWPASS_1 : H(s) = b0 / (s + a0) — num has only constant term
HIGHPASS_1 : H(s) = b1·s / (s + a0) — num has only s term
Second-order sections
---------------------
LOWPASS_2 : H(s) = b0 / (s² + …) — num has only b0
BANDPASS : H(s) = b1·s / (s² + …) — num has only b1·s
HIGHPASS_2 : H(s) = b2·s² / (s² + …) — num has only b2·s²
BANDSTOP : H(s) = (b2·s² + b0) / (s² + …) — num has b2·s² and b0
ALLPASS : num ≈ den (mirrored coefficients)
UNKNOWN : does not match any of the above patterns
"""
LOWPASS_1 = "LP1"
HIGHPASS_1 = "HP1"
LOWPASS_2 = "LP2"
BANDPASS = "BP"
HIGHPASS_2 = "HP2"
BANDSTOP = "BS"
ALLPASS = "AP"
UNKNOWN = "?"
@dataclass
class TransferFunction:
"""Represents H(s) = num(s) / den(s) as coefficient arrays (descending power)."""
numerator : np.ndarray = field(default_factory=lambda: np.array([1.0]))
denominator : np.ndarray = field(default_factory=lambda: np.array([1.0, 1.0]))
# Poles, zeros, gain
poles : np.ndarray = field(default_factory=lambda: np.array([]))
zeros : np.ndarray = field(default_factory=lambda: np.array([]))
gain : float = 1.0
# Set by factored_biquads() — tells synthesise_* what kind of section this is
section_type : SectionType = SectionType.UNKNOWN
# Set by compute_transfer_function() — the global filter type from the spec,
# carried down so netlist generators know the full context
filter_type : FilterType = FilterType.LOWPASS
class ComponentType(Enum):
RESISTOR = "R"
CAPACITOR = "C"
INDUCTOR = "L" # reserved for future use
@dataclass
class ComponentValue:
stage : int
name : str # e.g. "R1", "C2"
component_type : ComponentType # RESISTOR or CAPACITOR
ideal : float # Ohms or Farads
rounded : float # nearest E-series value
error_pct : float
section_type : SectionType = SectionType.UNKNOWN # LP2, BP, HP2, etc.
filter_type : FilterType = FilterType.LOWPASS # global filter context
def formatted_ideal(self) -> str:
"""Return ideal value as a human-readable string with SI prefix."""
return _si_format(self.ideal, self.component_type)
def formatted_rounded(self) -> str:
"""Return rounded value as a human-readable string with SI prefix."""
return _si_format(self.rounded, self.component_type)
def _si_format(value: float, ctype: ComponentType) -> str:
"""Format a component value with the appropriate SI prefix and unit."""
unit = "Ω" if ctype == ComponentType.RESISTOR else "F"
for threshold, prefix in (
(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k"),
(1.0, ""), (1e-3, "m"), (1e-6, "μ"), (1e-9, "n"),
(1e-12, "p"),
):
if abs(value) >= threshold:
return f"{value / threshold:.4g} {prefix}{unit}"
return f"{value:.4g} {unit}"
@dataclass
class SimulationResult:
"""Holds the SPICE AC-analysis result vectors."""
frequencies : np.ndarray = field(default_factory=lambda: np.array([]))
magnitude_db : np.ndarray = field(default_factory=lambda: np.array([]))
phase_deg : np.ndarray = field(default_factory=lambda: np.array([]))
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 1 ────────────────────────────────────────────────────
# Filter Order Calculator
# ──────────────────────────────────────────────────────────────────────────────
def compute_minimum_order(spec: FilterSpec) -> int:
"""
Compute the minimum integer filter order that satisfies *spec*.
Parameters
----------
spec : FilterSpec
Fully populated filter specification. Use spec.omega_p / spec.omega_s
(rad/s properties) for the selectivity ratio computation — do NOT use
the Hz fields directly in the prototype formulas.
Returns
-------
int
Minimum order n ≥ 1.
Notes
-----
── STUDENT CODE ──
Use the closed-form expressions for each approximation:
Butterworth : n ≥ log((10^(As/10)-1) / (10^(Ap/10)-1)) / (2·log(Ωs/Ωp))
Chebyshev I : n ≥ acosh(√((10^(As/10)-1) / ε²)) / acosh(Ωs/Ωp)
Chebyshev II : symmetric to Cheby-I via stopband selectivity
Elliptic : use Landen/elliptic-integral formulas or scipy.signal.ellipord
For BP/BS convert the two-sided spec to a LP prototype selectivity ratio first.
Always round up to the nearest integer (math.ceil).
"""
# Orden por default
n = 1
# Cociente de selectividad según tipo de filtro para las especificaciones
# Usar omega_ratio en vez de spec.omega_s/spec.omega_p directo,ya que eso solo
# funciona para LP. Aquí calculamos el cociente para cada caso.
if spec.filter_type == FilterType.LOWPASS:
# LP: directo, fs > fp entonces ratio > 1
omega_ratio = spec.omega_s / spec.omega_p
elif spec.filter_type == FilterType.HIGHPASS:
# HP: se invierte porque la transformación LP a HP voltea el eje de
# frecuencias. fp > fs en HP, al invertir obtenemos ratio > 1.
omega_ratio = spec.omega_p / spec.omega_s
#funcion para bandpass
elif spec.filter_type == FilterType.BANDPASS:
# BP: convertimos los dos bordes de rechazo al prototipo LP equivalente
# y tomamos el más restrictivo (el menor) para garantizar ambos bordes.
import math
omega_0 = math.sqrt(spec.omega_p * spec.omega_p2)
BW = spec.omega_p2 - spec.omega_p
Os1 = abs(spec.omega_s**2 - omega_0**2) / (BW * spec.omega_s)
Os2 = abs(spec.omega_s2**2 - omega_0**2) / (BW * spec.omega_s2)
omega_ratio = min(Os1, Os2)
#Funcion para bandstop
elif spec.filter_type == FilterType.BANDSTOP:
# BS: inverso del BP, el rechazo está en el centro.
import math
omega_0 = math.sqrt(spec.omega_p * spec.omega_p2)
BW = spec.omega_p2 - spec.omega_p
Os1 = (BW * spec.omega_s) / abs(spec.omega_s**2 - omega_0**2)
Os2 = (BW * spec.omega_s2) / abs(spec.omega_s2**2 - omega_0**2)
omega_ratio = min(Os1, Os2)
# Guardia: si el cociente es ≤ 1 las specs son inválidas
# (puede ocurrir en la llamada inicial de la GUI con valores default)
if omega_ratio <= 1.0:
print(f'(debug): orden del filtro inválido (Ωs={omega_ratio:.3f} ≤ 1), retornando 1')
return 1
# Calcula especificación
if spec.approximation == Approximation.BUTTERWORTH:
n = np.ceil(np.log((10**(spec.a_s/10)-1) / (10**(spec.a_p/10)-1)) / (2*np.log(omega_ratio))).astype(int)
elif spec.approximation == Approximation.CHEBYSHEV_I:
n = np.ceil(np.acosh(np.sqrt((10**(spec.a_s/10)-1) / spec.ripple_eps**2)) / np.acosh(omega_ratio)).astype(int)
elif spec.approximation == Approximation.CHEBYSHEV_II:
n = np.ceil(np.acosh(np.sqrt((10**(spec.a_s/10)-1) / spec.ripple_eps**2)) / np.acosh(omega_ratio)).astype(int)
elif spec.approximation == Approximation.ELLIPTIC:
import scipy.signal as sig
n, _ = sig.ellipord(wp=1.0, ws=omega_ratio, gpass=spec.a_p, gstop=spec.a_s, analog=True)
n = int(n)
# Regresa el orden del filtro
print(f'(debug): orden del filtro {n}')
return n
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 2 ────────────────────────────────────────────────────
# Prototype and Frequency-Transformed Transfer Function
# ──────────────────────────────────────────────────────────────────────────────
def compute_transfer_function(spec: FilterSpec) -> tuple[TransferFunction, int]:
"""
Compute the analogue transfer function H(s) for the given specification.
Steps expected:
1. Call compute_minimum_order(spec) to obtain n.
2. Compute LP prototype poles (and zeros for Cheby-II / Elliptic).
3. Apply LP→{LP|HP|BP|BS} frequency transformation using spec.omega_p/s.
4. Denormalise to the physical edge frequency.
5. Express as rational polynomial in *s*.
Parameters
----------
spec : FilterSpec
Returns
-------
(TransferFunction, int)
tf : TransferFunction — .numerator and .denominator are numpy arrays
of polynomial coefficients (highest power first, scipy convention).
.poles, .zeros, .gain are also populated.
order : int — the computed minimum order (displayed in the GUI).
Notes
-----
── STUDENT CODE ──
Hint: scipy.signal.{buttap, cheb1ap, cheb2ap, ellipap} return LP prototype
zeros, poles, gain. scipy.signal.lp2{lp,hp,bp,bs} perform the frequency
transformation. scipy.signal.zpk2tf converts to polynomial form.
Use spec.omega_p, spec.omega_s, spec.omega_p2, spec.omega_s2 (rad/s).
Use spec.ripple_eps for the prototype ripple parameter.
"""
# Calcula el orden del filtro con la funcion antes implementada
n = compute_minimum_order(spec)
# Calcula los prototipos normalizados pasabajas (wp= 1rad/s)
# cheb1ap recibe rp en dB (=a_p), No epsilon
# cheb2ap recibe rs en dB (=a_s), NO epsilon
# ellipap recibe rp y rs ambos en dB
match spec.approximation:
case Approximation.BUTTERWORTH:
#respuesta maximalmente plana
z, p, k = sps.buttap(n)
case Approximation.CHEBYSHEV_I:
# rp = rizado máximo en la banda de paso en dB
z, p, k = sps.cheb1ap(n, rp=spec.a_p)
case Approximation.CHEBYSHEV_II:
# rs = atenuación mínima en la banda de rechazo en dB
z, p, k = sps.cheb2ap(n, rs=spec.a_s)
case Approximation.ELLIPTIC:
# Necesita ambos: rizado en paso y atenuación en rechazo
z, p, k = sps.ellipap(n, rp=spec.a_p, rs=spec.a_s)
# ── Compute the correct prototype denormalisation frequency ──────────────
# Each approximation normalises the LP prototype differently:
#
# Butterworth : poles at 1 rad/s = the -3dB point, NOT the -Ap dB point.
# Must scale so that the -Ap dB point lands at omega_p:
# omega_c = omega_p / (10^(Ap/10) - 1)^(1/(2n))
#
# Chebyshev I : prototype passband edge is exactly at 1 rad/s (-Ap dB).
# omega_c = omega_p (no correction needed)
#
# Chebyshev II : prototype is normalised at the STOPBAND edge (1 rad/s = -As dB).
# omega_c = omega_s (use stopband edge, not passband)
#
# Elliptic : like Chebyshev I, passband edge at 1 rad/s (-Ap dB).
# omega_c = omega_p (no correction needed)
#
# For BP/BS the same correction applies to omega_p (and omega_s for Cheby-II),
# since lp2bp/lp2bs use BW which is already in physical rad/s.
match spec.approximation:
case Approximation.BUTTERWORTH:
# Correct cutoff so -Ap dB lands exactly at omega_p
omega_c = spec.omega_p / (10 ** (spec.a_p / 10) - 1) ** (1 / (2 * n))
case Approximation.CHEBYSHEV_I:
omega_c = spec.omega_p # passband edge = 1 rad/s in prototype
case Approximation.CHEBYSHEV_II:
omega_c = spec.omega_s # prototype normalised at stopband edge
case Approximation.ELLIPTIC:
omega_c = spec.omega_p # passband edge = 1 rad/s in prototype
# For BP/BS also compute the correct stopband-edge frequency for Chebyshev II
if spec.filter_type in (FilterType.BANDPASS, FilterType.BANDSTOP):
omega_0 = np.sqrt(spec.omega_p * spec.omega_p2)
BW = spec.omega_p2 - spec.omega_p
if spec.approximation == Approximation.CHEBYSHEV_II:
# Use the more restrictive of the two stopband edges
Os1 = abs(spec.omega_s **2 - omega_0**2) / (BW * spec.omega_s)
Os2 = abs(spec.omega_s2**2 - omega_0**2) / (BW * spec.omega_s2)
# omega_c for BP/BS Cheby-II is incorporated via BW scaling
# (lp2bp/lp2bs handle the centre frequency and BW directly)
# TRANSFORMACION DE FRECUENCIA LP a {LP,HP,BP,BS}, AQUI SE OBTIENEN FRECUENCIAS DE CORTE
# Usamos z,p,k en lugar de polinomios para hacerlo mas estable con ordenes altos
# lp2lp_zpk: escala la frecuencia de 1 rad/s a omega_p real
# lp2hp_zpk: invierte el eje de frecuencias y escala a omega_p
# lp2bp_zpk: abre la banda alrededor de omega_0 con ancho BW
# lp2bs_zpk: inverso del BP , rechazo en el centro
match spec.filter_type :
case FilterType.LOWPASS:
print(f'(debug): lp frec desnormalización: {omega_c/(2*np.pi)} Hz')
z, p, k = sps.lp2lp_zpk(z, p, k, wo=omega_c)
case FilterType.HIGHPASS:
print(f'(debug): hp frec desnormalización: {omega_c/(2*np.pi)} Hz')
z, p, k = sps.lp2hp_zpk(z, p, k, wo=omega_c)
case FilterType.BANDPASS:
omega_0 = np.sqrt(spec.omega_p * spec.omega_p2)
BW = spec.omega_p2 - spec.omega_p
print(f'(debug): bp frec desnormalización: {omega_0/(2*np.pi)} Hz')
print(f'(debug): bp bandwidth: {BW/(2*np.pi)} Hz')
z, p, k = sps.lp2bp_zpk(z, p, k, wo=omega_0, bw=BW)
case FilterType.BANDSTOP:
omega_0 = np.sqrt(spec.omega_p * spec.omega_p2)
BW = spec.omega_p2 - spec.omega_p
print(f'(debug): bs frec desnormalización: {omega_0/(2*np.pi)} Hz')
print(f'(debug): bs bandwidth: {BW/(2*np.pi)} Hz')
z, p, k = sps.lp2bs_zpk(z, p, k, wo=omega_0, bw=BW)
# Calcula los polinomios de la función de transferencia
# Convertir ZPK a coeficientes de polinomio
# zpk2tf convierte zeros,polos,ganancia a coeficientes num/den en orden
# descendente de potencias: [b_n, b_{n-1}, b_0] / [a_n, a_0]
# np.real() elimina la parte imaginaria residual de punto flotante (~1e-16).
# Los coeficientes deben ser reales porque los polos complejos siempre son pares conjugados.
num, den = sps.zpk2tf(z, p, k)
# objeto TransferFunction
# Se necesita los coeficientes para graficar H(jω) y los polos/zeros para el mapa polo-cero.
tf = TransferFunction(
numerator = np.real(num),
denominator = np.real(den),
poles = p,
zeros = z,
gain = float(np.real(k)),
filter_type = spec.filter_type, # carry spec context into TF
)
print(f'(debug): num: {tf.numerator}')
print(f'(debug): den: {tf.denominator}')
# Regresa el objeto función de transferencia y el orden
return (tf, n)
#raise NotImplementedError("STUDENT: implement compute_transfer_function()")
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 3 ────────────────────────────────────────────────────
# Biquad / Stage Decomposition
# ──────────────────────────────────────────────────────────────────────────────
def _classify_section(num: np.ndarray, den: np.ndarray, tol: float = 1e-4) -> SectionType:
"""
Classify a biquad section by inspecting its numerator coefficient pattern.
Parameters
----------
num : np.ndarray – numerator coefficients [b2, b1, b0] (length ≤ 3)
den : np.ndarray – denominator coefficients [a2, a1, a0] (length ≤ 3)
tol : float – relative threshold below which a coefficient is
considered zero (default 1e-4)
Returns
-------
SectionType
Notes
-----
Instructor-provided — do NOT modify.
Numerator patterns (after padding to length 3: [b2, b1, b0]):
[0, 0, *] → LOWPASS_2 (only constant term)
[0, *, 0] → BANDPASS (only s term)
[*, 0, 0] → HIGHPASS_2 (only s² term)
[*, 0, *] → BANDSTOP (s² and constant, no s term)
[*, *, *] → ALLPASS if num ≈ reversed(den), else UNKNOWN
For first-order sections (den has 2 coefficients):
[0, *] → LOWPASS_1
[*, 0] → HIGHPASS_1
"""
# Normalise to leading coefficient of denominator
scale = abs(den[0]) if abs(den[0]) > 1e-30 else 1.0
order = len(den) - 1 # 1 or 2
# Pad numerator to match denominator length
b = np.zeros(order + 1)
b_src = np.real(num)
b[-(len(b_src)):] = b_src
# Relative significance mask
sig = np.abs(b) / scale > tol
if order == 1:
# First-order section
if sig[0] and not sig[1]:
return SectionType.HIGHPASS_1
if sig[1] and not sig[0]:
return SectionType.LOWPASS_1
return SectionType.UNKNOWN
# Second-order section: sig = [b2_sig, b1_sig, b0_sig]
b2, b1, b0 = sig
if not b2 and not b1 and b0:
return SectionType.LOWPASS_2
if not b2 and b1 and not b0:
return SectionType.BANDPASS
if b2 and not b1 and not b0:
return SectionType.HIGHPASS_2
if b2 and not b1 and b0:
return SectionType.BANDSTOP
if b2 and b1 and b0:
# Check for allpass: num coefficients ≈ reversed den coefficients
den_norm = np.real(den) / den[0]
num_norm = b / (b[0] if abs(b[0]) > 1e-30 else 1.0)
if np.allclose(num_norm, den_norm[::-1], rtol=tol * 10):
return SectionType.ALLPASS
return SectionType.UNKNOWN
def factored_biquads(tf: TransferFunction) -> list[TransferFunction]:
"""
Split H(s) into a cascade of first- and second-order sections (biquads).
Parameters
----------
tf : TransferFunction
Full-order transfer function returned by compute_transfer_function().
Returns
-------
list[TransferFunction]
Each element is a 1st- or 2nd-order section, ordered for minimum
intermediate signal dynamics (Q-ordered pairing recommended).
Notes
-----
── STUDENT CODE ──
Use scipy.signal.tf2sos then convert each row back to a TransferFunction.
Apply Q-ordered pairing and output-ordering optimisation.
"""
# Arreglo de secciones
sections = []
# Calcula los ceros y los polos, ordenados por pares conjugados
z = np.sort_complex(np.roots(tf.numerator))
p = np.sort_complex(np.roots(tf.denominator))
# Calcula el orden del filtro
n = tf.denominator.shape[0] - 1
# Genera las secciones segun n y tipo de filtro
match tf.filter_type:
case FilterType.LOWPASS:
scale = tf.numerator[-1]**(1/n)
if n%2:
# Sección de primer orden
seccion = TransferFunction(
numerator = np.real(np.array([scale])),
denominator = np.real(np.array([1, -p[0]])),
section_type = SectionType.LOWPASS_1,
filter_type = tf.filter_type)
sections.append(seccion)
p = p[1:]
for i in range(0,p.shape[0],2):
# Sección de primer orden
seccion = TransferFunction(
numerator = np.real(np.array([scale**2])),
denominator = np.real(np.poly(p[i:i+2])),
section_type = SectionType.LOWPASS_2,
filter_type = tf.filter_type)
sections.append(seccion)
case _:
# Factoriza en secciones de segundo orden
sos = sps.tf2sos(tf.numerator, tf.denominator, pairing='minimal', analog=True)
# Genera los objetos TransferFunction para cada seccion,
# clasificando cada una y propagando el filter_type global.
sections = []
for row in sos:
num, den = row[:3], row[3:]
sec = TransferFunction(
numerator = num,
denominator = den,
section_type = _classify_section(num, den),
filter_type = tf.filter_type, # carry global context down
)
sections.append(sec)
# ----- Q-ordering (inline) -----
def compute_Q(section):
a = section.denominator
if len(a) == 3:
a1 = a[1]
a2 = a[2]
if a1 != 0 and a2 > 0:
return np.sqrt(a2) / a1
return 0.0
sections = sorted(sections, key=compute_Q)
for i, s in enumerate(sections):
print(f'(debug) S{i+1}: {s.section_type}')
print(f' {s.filter_type}')
print(f' num: {s.numerator}')
print(f' den: {s.denominator}')
return sections
# raise NotImplementedError("STUDENT: implement factored_biquads()")
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 4 ────────────────────────────────────────────────────
# Frequency Response (Theoretical)
# ──────────────────────────────────────────────────────────────────────────────
def compute_frequency_response(
tf: TransferFunction,
f_start: float = (0.01)*2*np.pi,
f_stop: float = (1e2)*2*np.pi,
n_points: int = 1000,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Evaluate H(jω) over a logarithmic frequency sweep.
Parameters
----------
tf : TransferFunction
f_start : float – start frequency in Hz
f_stop : float – stop frequency in Hz
n_points : int – number of frequency points
Returns
-------
(freqs_hz, magnitude_db, phase_deg, group_delay_s)
All four arrays have length n_points.
freqs_hz : np.ndarray – frequency axis in Hz
magnitude_db : np.ndarray – |H(jω)| in dB
phase_deg : np.ndarray – unwrapped phase in degrees
group_delay_s : np.ndarray – group delay τ(ω) = -dφ/dω in seconds
Notes
-----
── STUDENT CODE ──
Use scipy.signal.freqs (analogue) to evaluate the transfer function.
Unwrap the phase with np.unwrap before converting to degrees.
Group delay = -d(phase_rad)/d(omega). Use np.gradient for numerical diff.
"""
# Eje logarítmico de frecuencias en Hz
freqs_hz = np.logspace(np.log10(f_start), np.log10(f_stop), n_points)
# Mismo eje, en rad/s
omega = 2*np.pi*freqs_hz
# Calcula la respuesta en frecuencia
w, H = sps.freqs(tf.numerator, tf.denominator, worN=omega)
# Calcula la magnitud en dB
magnitude_db = 20*np.log10(np.abs(H))
# Calcula la fase en rad/s y grados
phase_rad = np.unwrap(np.angle(H))
phase_deg = np.degrees(phase_rad)
# Calcula el retardo de grupo
dphi_domega = np.gradient(phase_rad, omega)
group_delay_s = -dphi_domega
# Regresa la tupla de arreglos calculados
return (freqs_hz, magnitude_db, phase_deg, group_delay_s)
# raise NotImplementedError("STUDENT: implement compute_frequency_response()")
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 5 ────────────────────────────────────────────────────
# Component Synthesis (Sallen-Key)
# ──────────────────────────────────────────────────────────────────────────────
def synthesise_sallen_key(
biquads: list[TransferFunction],
r_base: float,
c_base: float,
r_series: ESeries,
c_series: ESeries,
) -> list[ComponentValue]:
"""
Compute R and C values for a Sallen-Key cascade realising *biquads*.
Parameters
----------
biquads : list[TransferFunction] – from factored_biquads()
r_base : float – base/scaling resistor value (Ω)
c_base : float – base/scaling capacitor value (F)
r_series : ESeries – target E-series for resistors
c_series : ESeries – target E-series for capacitors
Returns
-------
list[ComponentValue]
One entry per component per stage, populated with ideal, rounded, and
error_pct fields.
Notes
-----
── STUDENT CODE ──
For each biquad inspect biquad.section_type to determine the circuit:
SectionType.LOWPASS_2 → Sallen-Key LP (equal-C design)
SectionType.HIGHPASS_2 → Sallen-Key HP (equal-R design, swap R↔C roles)
SectionType.LOWPASS_1 → Single RC + voltage follower (1st-order LP)
SectionType.HIGHPASS_1 → Single RC + voltage follower (1st-order HP)
Also check biquad.filter_type for the global filter context if needed.
Use round_to_eseries() to snap to standard values.
"""
components = []
for stage_idx, biquad in enumerate(biquads):
stage_num = stage_idx + 1
ftype = biquad.filter_type
#Limpiar el denominador de ceros lideres
num_work = np.array(biquad.numerator, dtype = float)
den_work = np.array(biquad.denominator, dtype = float)
while len(den_work) > 1 and abs(den_work[0]) < 1e-20:
den_work = den_work[1:]
num_work = num_work[1:]
#Reclasifica la seccion con los arrays limpios
#Necesario cuando el biquad venia con cero lider y section_type incorrecto
stype = _classify_section(num_work, den_work)
print(f" [synthesise] Etapa {stage_num}: "
f"section_type original = {biquad.section_type.value} ->"
f"corregido = {stype.value} "
f"num= {np.round(num_work, 4)} den = {np.round(den_work, 4)}")
#Normaliza coeficiente lider del denominador a 1
den = den_work / den_work[0]
if stype == SectionType.LOWPASS_2:
# Sallen-Key Pasa Bajas - Diseño de igual C
a1 = den[-2]
a0 = den[-1]
C = c_base
P = 1.0 / (a0 * C**2) #Producto R1 y R2
S = a1 / (a0 * C) #Suma R1 y R2
discriminant = max(S**2 - 4*P, 0.0) #Sujetar ruido numerico
R1_ideal = (S + np.sqrt(discriminant)) / 2.0
R2_ideal = (S - np.sqrt(discriminant)) / 2.0
C1_ideal = C
C2_ideal = C
componentes_etapa = [
("R1", ComponentType.RESISTOR, R1_ideal),
("R2", ComponentType.RESISTOR, R2_ideal),
("C1", ComponentType.CAPACITOR, C1_ideal),
("C2", ComponentType.CAPACITOR, C2_ideal),
]
elif stype == SectionType.HIGHPASS_2:
# Sallen-Key Pasa Altas - Diseño de igual R
a1 = den[-2]
a0 = den[-1]
R = r_base
P = 1.0 / (a0 * R**2) #Producto C1 y C2
S = a1 / (a0 * R) #Suma C1 y C2
discriminant = max(S**2 - 4*P, 0.0)
C1_ideal = (S + np.sqrt(discriminant)) / 2.0
C2_ideal = (S - np.sqrt(discriminant)) / 2.0
R1_ideal = R
R2_ideal = R
componentes_etapa = [
("R1", ComponentType.RESISTOR, R1_ideal),
("R2", ComponentType.RESISTOR, R2_ideal),
("C1", ComponentType.CAPACITOR, C1_ideal),
("C2", ComponentType.CAPACITOR, C2_ideal),
]
elif stype == SectionType.BANDPASS:
# Sallen-Key Pasa Bandas - Topologia de multiple retroalimentacion
a1 = den[-2]
a0 = den[-1]
omega0 = np.sqrt(a0)
Q = omega0 / a1 #Q = w0 / (w0/Q)
C = c_base
R1_ideal = Q / (omega0 * C) #Resistencia de entrada
R2_ideal = 1.0 / (Q * omega0 *C) #Resistencia de retroalimentación
C1_ideal = C
C2_ideal = C
componentes_etapa = [
("R1", ComponentType.RESISTOR, R1_ideal),
("R2", ComponentType.RESISTOR, R2_ideal),
("C1", ComponentType.CAPACITOR, C1_ideal),
("C2", ComponentType.CAPACITOR, C2_ideal),
]
elif stype == SectionType.BANDSTOP:
# Red Twin-T Rechaza-Banda
a0 = den[-1]
omega0 = np.sqrt(a0)
R = r_base
C = 1.0 / (omega0 * R) # Valor base de capacitor
R1_ideal = R
R2_ideal = R
R3_ideal = R / 2.0 #Resistencia central = R/2
C1_ideal = C
C2_ideal = C
C3_ideal = 2.0 * C #Capacitor central = 2C
componentes_etapa = [
("R1", ComponentType.RESISTOR, R1_ideal),
("R2", ComponentType.RESISTOR, R2_ideal),
("R3", ComponentType.RESISTOR, R3_ideal),
("C1", ComponentType.CAPACITOR, C1_ideal),
("C2", ComponentType.CAPACITOR, C2_ideal),
("C3", ComponentType.CAPACITOR, C3_ideal),
]
elif stype == SectionType.LOWPASS_1:
# RC Pasa Bajas de primer orden + Seguidor de Tension
a0 = den[-1]
omega0 = a0
C_ideal = c_base
R_ideal = 1.0 / (omega0 * C_ideal)
componentes_etapa = [
("R1", ComponentType.RESISTOR, R_ideal),
("C1", ComponentType.CAPACITOR, C_ideal),
]
elif stype == SectionType.HIGHPASS_1:
# RC Pasa Altas de primer orden + Seguidor de Tension
a0 = den[-1]
omega0 = a0
R_ideal = r_base
C_ideal = 1.0 / (omega0 * R_ideal)
componentes_etapa = [
("R1", ComponentType.RESISTOR, R_ideal),
("C1", ComponentType.CAPACITOR, C_ideal),
]
else:
# Tipo de sección no soportado (ALLPASS, UNKNOWN) - Se omite
print(f" [Advertencia] Etapa {stage_num}: sección tipo '{stype.value}' "
f"no soportada, se omite.")
continue
# Redondea a sere E y registra cada componente
for name, ctype, ideal_val in componentes_etapa:
serie = r_series if ctype == ComponentType.RESISTOR else c_series
rounded = round_to_eseries(ideal_val, serie)
components.append(ComponentValue(
stage = stage_num,
name = name,
component_type = ctype,
ideal = ideal_val,
rounded = rounded,
error_pct = eseries_error_pct(ideal_val, rounded),
section_type = stype,
filter_type = ftype,
))
return components
# ──────────────────────────────────────────────────────────────────────────────
# ── STUDENT ENTRY POINT 6 ────────────────────────────────────────────────────
# Component Synthesis (Tow-Thomas / UAF42)
# ──────────────────────────────────────────────────────────────────────────────
def synthesise_tow_thomas(
biquads: list[TransferFunction],
r_base: float,
c_base: float,
r_series: ESeries,
c_series: ESeries,
) -> list[ComponentValue]:
components = []
# Valores internos/fijos del UAF42 usados en el análisis:
# R1 = R2 = R4 = R = 50kΩ
# y se elige RG = RQ = R
# los capacitores de las secciones de segundo orden son fijos a 1000pF
R_INTERNAL = 50e3
C_INTERNAL = 1000e-12
C = c_base
for stage_idx, biquad in enumerate(biquads):
stage = stage_idx + 1
den = np.asarray(biquad.denominator, dtype=float)
# ─────────────────────────────────────────────────────────────
# CASO A: sección de primer orden normal
# den = [a0, a1] → a0*s + a1
# ωc = a1/a0
# ─────────────────────────────────────────────────────────────
if len(den) == 2:
a0, a1 = den
if abs(a0) < 1e-12:
print(f"debug stage {stage}: primer orden inválido, saltando")
continue
omega_c = abs(a1 / a0)
CP = C
RP = 1.0 / (omega_c * CP)
CP_r = round_to_eseries(CP, c_series)
RP_r = round_to_eseries(RP, r_series)
for name, val, val_r, ctype in (
("CP", CP, CP_r, ComponentType.CAPACITOR),
("RP", RP, RP_r, ComponentType.RESISTOR),
):
components.append(ComponentValue(
stage = stage,
name = name,
component_type = ctype,
ideal = val,
rounded = val_r,
error_pct = eseries_error_pct(val, val_r),
section_type = biquad.section_type,
filter_type = biquad.filter_type,
))
print(
f"debug stage {stage}: 1er orden "
f"ωc={omega_c:.2f} rad/s, "
f"RP={RP:.2f}Ω, CP={CP:.2e}F"
)
continue
# ─────────────────────────────────────────────────────────────
# CASO B: sección de primer orden disfrazada
# scipy.tf2sos puede devolver den = [0, a1, a2]
# que equivale a a1*s + a2
# ─────────────────────────────────────────────────────────────
if len(den) == 3 and abs(den[0]) < 1e-12:
a0, a1, a2 = den
if abs(a1) < 1e-12:
print(f"debug stage {stage}: sección degenerada, saltando")
continue
omega_c = abs(a2 / a1)
CP = C
RP = 1.0 / (omega_c * CP)
CP_r = round_to_eseries(CP, c_series)
RP_r = round_to_eseries(RP, r_series)
for name, val, val_r, ctype in (
("CP", CP, CP_r, ComponentType.CAPACITOR),
("RP", RP, RP_r, ComponentType.RESISTOR),
):
components.append(ComponentValue(
stage = stage,
name = name,
component_type = ctype,
ideal = val,
rounded = val_r,
error_pct = eseries_error_pct(val, val_r),
section_type = biquad.section_type,
filter_type = biquad.filter_type,
))
print(
f"debug stage {stage}: 1er orden "
f"ωc={omega_c:.2f} rad/s, "
f"RP={RP:.2f}Ω, CP={CP:.2e}F"
)
continue
# ─────────────────────────────────────────────────────────────
# CASO C: sección de segundo orden Tow-Thomas / UAF42
# den = [a0, a1, a2] → a0*s² + a1*s + a2
# normalizado:
# s² + (ω0/Q)s + ω0²
# ─────────────────────────────────────────────────────────────
if len(den) == 3:
a0, a1, a2 = den
if abs(a0) < 1e-12 or abs(a1) < 1e-12: