サイトマップ

記事一覧を取得中...

過電圧で壊れたESP32 DevKit V1のモジュールを張り替えて復活させてみた


1. 事故発生!突然の再起動ループと「指で触ると激熱」のパニック

ESP32(DevKit V1)を使って実験中、ある時からオシロで見ていたパルス波形が乱れ始めました。そして、 シリアルモニタに不穏なログが流れ始め、無限再起動ループに陥ってしまったのです。

13:21:49.163 -> E BOD: Brownout detector was triggered
13:21:49.163 -> 
13:21:49.163 -> 
13:21:49.163 -> ets Jun  8 2016 00:22:57
13:21:49.196 -> 
13:21:49.196 -> rst:0x3 (SW_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
13:21:49.196 -> configsip: 0, SPIWP:0xee
13:21:49.196 -> clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
13:21:49.196 -> mode:DIO, clock div:1
13:21:49.196 -> load:0x3fff0030,len:4640
13:21:49.196 -> load:0x40078000,len:15592
13:21:49.196 -> load:0x40080400,len:3164
13:21:49.196 -> entry 0x4008059c

ESP32をブレッドボードから取り外し、単体にしても状況は変わりません。さらに電流計を見ると、なんと 0.52A もの異常な電流が流れています!

恐る恐る基板に触れてみると、3.3Vレギュレータ(AMS1117)とESP32本体が指で触り続けられないほどアツアツに加熱していました。

【故障時の状態】

写真1:故障時の状態


2. ログ解読と右往左往:何が起きているのか?

ログ解読:流れていたログの意味を読み解いてみます。

  1. E BOD: Brownout detector was triggered 供給電圧(3.3Vライン)が閾値(約2.4〜2.8V)以下まで急降下したため、チップの保護機能(Brownout)が作動。
  2. rst:0x7 (TG0WDT_SYS_RESET) 電圧降下によってCPU処理が停止し、タスク・ウォッチドッグ(WDT)がタイムアウトして強制リセット。
  3. rst:0x3 (SW_RESET) → 再起動ループ リセットしても電源がショート状態のため、再びBrownoutを起こして永久ループ。

テスターで測ると、IO34-GND間 とボード全体の 3.3V-GND間がほぼ0Ω(ショート) になっていました。

異常発生のメカニズムを推定

  • 異常発生前を振り返ると、ローサイド置きNMOSからハイサイドのPMOSへの回路変更をおこないました。
  • このとき、PMOSへの変更にあわせて分圧比を変更したGate電圧をIO33に入力したので、分圧後の電源電圧に近い過電圧がIO33に入ったことになります。5.2Vでした。
  • ESP32の内部では、IO33の保護ダイオードを通じて3.3V電源レールに大電流が逆流し、ESP32内部の「3.3V - GND」間が溶融・短絡したと考えられます。
  • 各ピンの導通チェックしたところ、過電圧が入ったはずのIO33を含め、他のピン(同じ入力専用のIO35など)はショートしておらず、IO34と3.3V-GND間だけがピンポイントでショートしていました。
  • IC内部で過電流が抜ける際、IO34の内部保護素子がピンポイントで熱溶着(EOS破壊)を起こし、3.3VラインとGNDを道連れにしてショートしたものと考えられます。IO34が構造的に弱さがあるのかもしれません。

3. 疑心暗鬼からの推理:「レギュレータもCP2102も全滅なのか?」

「5.2Vを入れたから、AMS1117(レギュレータ)もCP2102(USBシリアル変換IC)も全部焼き切れたのでは……」と絶望です。以前にも何度か壊していました。今回も一旦はゴミ箱へ入れました。

しかし、思い直して冷静に回路図とデータシートを調べてみると、希望が見えてきます。

  • AMS1117は本当に壊れた?
    AMS1117の最大入力電圧は 15V です。5.2V程度の電圧で即死することはありません。3.3Vラインがショートしているのは、ESP32内部の保護ダイオードが過電圧で溶けて3.3VとGNDが物理的に短絡したから ではないか?
  • CP2102は無事か?
    IO34(入力専用ピン)はCP2102のUARTピン(TX/RX)とは直接つながっていません。ESP32が身代わりになってショートしてくれたおかげで、CP2102は無傷で生き残っている可能性があります。

「ESP32モジュールさえ剥がせば、基板側は生きているかもしれない!」


4. 運命の切り分け:ヒートガンでESP32を剥がしてみた

周りの小部品(チップ抵抗やボタン)をカプトンテープで保護し、ヒートガン(350℃)で熱風を当ててESP32モジュールを取り外しました。1~2分くらい熱していたと思います。

【ESP32取り外し】 ある瞬間にポロッと剥がれます。

写真2:ヒートガンでのESP32取り外し作業

基板に残ったハンダは吸い取り線できれいに除去しておきます。中央に4mm角ほどのGNDパッドがあり、ハンダが残っていました。

写真3:ESP32が剥がされた基板とESP32の裏側

剥がしたICの各ピンの導通チェックしたところ、ボードでのチェックと同じく、IO34-GND間 と 3.3V-GND間がショート していました。

次に、モジュールを取り外した「裸の基板」で、再びテスターを当ててみます。

  • すると…… 見事に3.3V-GND間のショートが解消! 基板側のパターン抵抗値が正常な値(数MΩ以上)に戻りました。
  • この状態でMicroUSBケーブルをPCに接続してみると…… AMS1117(レギュレータ)の3.3Vも正常に出ています。
  • デバイスマネージャーに「Silicon Labs CP210x USB to UART Bridge」のCOMポートが出現!しました。

写真4:デバイスマネジャーにCOM6が出現

レギュレータもUSBシリアルICも無傷です!あとは新しいESP32を載せれば復活するはずです。


5. 新モジュール(ESP32-WROOM-32E-N16)への載せ替え

ベース基板の無事が確認できたので、新しいCPUモジュールをはんだ付けします。

今回選んだのは、AliExpressで購入した ESP32-WROOM-32E-N16 です。以前は秋月でN4(4MB)が¥400しなかったと思うのですが、今はもう扱いがありません。

チップ選定の余談

  • 以前は、AliExpressで開発ボードESP32 DEVKIT V1 が¥400 くらいでした。今は、安いショップで¥750 くらいです。そもそも、以前より扱いが少ない気がします。
  • 主流はデュアルならESP32-S3、シングルで安さ優先ならC3、C6 等へ移っているようです。
  • IC単体も扱いがほとんどありません。探し当てたのか下記です。
  • WROVER-Eは魅力的ですが、残念ながら剥がしたボードには載りません。
品名 扱い Core Flash PSRAM 価格
DEVKIT V*(ボード) Amazon LX6×2 4MB 0 ¥1,280
WROOM-32E-N16 秋月 LX6×2 16MB 0 ¥700
WROOM-32E-N4 Aliexpress LX6×2 4MB 0 ¥471
WROOM-32E-N8 Aliexpress LX6×2 8MB 0 ¥496
WROOM-32E-N16 Aliexpress LX6×2 16MB 0 ¥546
WROVER-E Aliexpress LX6×2 4/8/16MB 8MB ¥589/672/730

入手したESP32Eには技適マークと認証番号も刻印されています。秋月32E-N16と同じような刻印デザインです。

新旧ESP32
写真5:左;壊れた32、右;購入した新ESP32E

  • なぜ16MB(N16)なのか?
    過去には標準的な4MB版(N4)が主流でしたが、最近はESP-IDFやMicroPython、HomeKit等のファームウェア肥大化に伴い、市場のデファクトスタンダードが16MB品に移行してるらしいです。N16なら、S3にした方が賢明な気もしますが、遊び心です。
  • なぜ「32E」なのか?
    初期の「無印(WROOM-32)」にあったシリコンバグ(RTC GPIOの挙動やPSRAMキャッシュ等の不具合)がハードウェアレベルで修正された最新リビジョン(ECO V3)らしいです。

手はんだ付け

  1. ランドの平坦化(最重要): はんだ吸い取り線とフラックスで、基板側パッドに残った古いハンダを完全に吸い取り、フラットにします。
  2. 位置決めと仮固定: 四隅のピン位置を正確に合わせ、対角の1〜2ピンを仮止めします。
  3. 裏面中央のGNDサーマルパッド(4mm角)の扱い: 手はんだリペアでの最大の難所ですが、無理につけなくても大丈夫です。左右の外周GNDピン(1番、15番、38番ピンなど)が接続されていれば、通常の電子工作用途で動作・放熱ともに全く問題ありません。
  4. 外周ピンのはんだ付け: フラックスを多めに塗り、コテ先で基板パッドとモジュール端子を同時に温めながらハンダを流し込みます。

6. 動作確認と復旧

慎重にハンダ付けしたのですが、少しずれました。ピンとパッドの導通や隣との絶縁を確認します。

はんだ付け完了後、USBを繋ぐ前に 必ずテスターで3.3V-GND間が再ショートしていないか を確認します。

問題ないことを確認してPCに接続!

載せ替え後にプログラムを書き込む際の流れと注意点

  • Arduinoなどの一部のマイコン(ATmega328Pなど)とは異なり、ESP32には工場出荷時点で非常に強力な「ハードウェア(ROM)ブートローダ」が最初から内蔵されています。
  • プログラムを書き込む際、コードと一緒にアプリケーションを動かすための「セカンドステージ・ブートローダ」や「パーティションテーブル」という制御用データが自動的に一括でフラッシュメモリに書き込まれます。
  • 載せ替え直後は、正しくはんだ付けできているか不安なものです。いきなり重いコンパイルを挟むArduino IDEでの書き込みを試す前に、載せ替え後の「疎通確認(生存確認)」を兼ねて、軽量な esptool で erase_flash や flash_id(チップ情報の読み出し)を実行することで、「PCとESP32が正常にシリアル通信できているか」を確実に切り分けることができます。

Step by Step

PCに接続すると、Arduino IDEのシリアルモニタからいきなり不穏なログが流れ始め、ビビります。

この invalid header: 0xffffffff というエラーが高速でループしている理由は、「フラッシュメモリの中身が完全に空っぽ(または初期化状態)のため、起動すべきプログラムが見つからない」という状態だからです。フラッシュメモリが空の時、データがすべて 0xFF になるため、このエラーが出ます。

つまり、現在の状態は想定通りの正常なステップです。ここからプログラムを書き込めば解決します。

17:15:37.693 -> 
17:15:37.693 -> rst:0x10 (RTCWDT_RTC_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
17:15:37.739 -> invalid header: 0xffffffff
17:15:37.785 -> invalid header: 0xffffffff
17:15:37.832 -> invalid header: 0xffffffff
17:15:37.879 -> invalid header: 0xffffffff
17:15:37.926 -> invalid header: 0xffffffff
17:15:37.972 -> invalid header: 0xffffffff
17:15:38.018 -> invalid header: 0xffffffff
17:15:38.018 -> ets Jul 29 2019 12:21:46

スケッチの書き込み

エラーもなく一発でLチカスケッチの書き込みが成功しました!。

シリアルモニタにもIC情報書き出しスケッチの正常なログが出力され、見事に復活しました!Flash容量 16 MB が認識されています。

info_ESP32.ino

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("\n====================================");
  Serial.println("       ★ ESP32 チップ情報 ★       ");
  Serial.println("====================================");

  // チップモデル名(例: ESP32-D0WDQ6)
  Serial.printf("チップモデル     : %s\n", ESP.getChipModel());
  
  // CPUのコア数(通常は 2 コア)
  Serial.printf("CPUコア数        : %d コア\n", ESP.getChipCores());
  
  // チップのリビジョン
  Serial.printf("チップリビジョン : v%d.0\n", ESP.getChipRevision());
  
  // フラッシュメモリの実装サイズ
  uint32_t flashSize = ESP.getFlashChipSize();
  Serial.printf("Flash容量        : %d バイト (%d MB)\n", flashSize, flashSize / (1024 * 1024));
  
  // フラッシュメモリの動作クロック速度
  Serial.printf("Flash動作速度    : %d MHz\n", ESP.getFlashChipSpeed() / 1000000);

  // 固有の固有MACアドレス(チップ固有のIDになります)
  uint64_t chipid = ESP.getEfuseMac();
  Serial.printf("MACアドレス      : %04X%08X\n", (uint16_t)(chipid >> 32), (uint32_t)chipid);
  
  Serial.println("====================================");
}

void loop() {
  // 1回表示すればいいのでloop内は空欄
}

17:40:35.241 -> ====================================
17:40:35.241 -> ★ ESP32 チップ情報 ★ 
17:40:35.241 -> ====================================
17:40:35.241 -> チップモデル : ESP32-D0WD-V3
17:40:35.241 -> CPUコア数 : 2 コア
17:40:35.285 -> チップリビジョン : v301.0
17:40:35.285 -> Flash容量 : 16777216 バイト (16 MB)
17:40:35.285 -> Flash動作速度 : 80 MHz
17:40:35.285 -> MACアドレス : 788A7AC92DF4
17:40:35.285 -> ====================================

同じことを esptool でもやってみます。チップ型番: ESP32-D0WD-V3 (revision v3.1)、Flash容量: Detected flash size: 16MB と出ます。

PS C:\Users\[user]\Documents> python -m esptool --port COM6 flash_id
WARNING: Deprecated: Command 'flash_id' is deprecated. Use 'flash-id' instead.
esptool v5.6.0
Connected to ESP32 on COM6:
Chip type:          ESP32-D0WD-V3 (revision v3.1)
Features:           Wi-Fi, BT, Dual Core + LP Core, 240MHz, Vref calibration in eFuse, Coding Scheme None
Crystal frequency:  40MHz
MAC:                f4:2d:c9:7a:8a:78
Stub flasher running.
Flash Memory Information:
Manufacturer: a1
Device: 4018
Detected flash size: 16MB
Flash voltage set by a strapping pin: 3.3V
Hard resetting via RTS pin...
PS C:\Users\[user]\Documents>

16MBのフラッシュメモリを活かすための設定(重要)

Arduino IDEのデフォルト設定(デフォルトのボード選択)のままだと、せっかく16MBある容量のうち「4MB分」しか認識・使用していない状態になっています。

それを変更していきます。

1. ボード設定の変更

ボード設定を汎用の「ESP32 Dev Module」に変更します。

  1. Arduino IDEのメニューから 「ツール」 > 「ボード」 > 「esp32」 と進みます。
  2. リストの中から ESP32 Dev Module を選択してください。
  3. 選択すると、「ツール」メニューの中に 「Flash Size」 や 「Partition Scheme」 の設定項目が出現します。そこで 「16MB」 を選べるようになります。

2. 16MB 設定

  1. Flash Size の変更:「ツール」 > 「Flash Size」 > 「16MB (128Mb)」 を選択します。
  2. Partition Scheme(パーティション構成)の変更:「ツール」 > 「Partition Scheme」 を開きます。
    • 16MB用の選択肢(例: 16M Flash (3MB APP/9MB FATFS) や 16M Flash (3MB APP/12.5MB FATFS) など)を選びます

知識:そもそも「Flash」と「パーティション」とは?

ESP32におけるフラッシュメモリは、単にプログラムを入れるだけでなく、PCのハードディスクのように「いくつかの部屋(パーティション)」に区切られて管理されています。

一般的な4MBのままだと部屋が狭く、大きなプログラムを書いたりデータを保存したりするとすぐに容量不足(sketch too large)になります。今回16MBに貼り替えたことで、それぞれの部屋を圧倒的に広く使えるようになります。

16MB用の設定を選ぶと、内部は主に以下の3つの部屋に自動で切り分けられます。

  • APP領域(プログラムを入れる部屋)
    自作したスケッチが書き込まれる場所です。4MB時は約1.2MB程度ですが、16MBにすると3MB以上まで拡張可能です。
  • OTA領域(無線書き込み用のバックアップ部屋)
    Wi-Fi経由でプログラムをアップデート(OTA)する場合、一時的に新プログラムを置いておくための同サイズの部屋が確保されます。
  • データ保存領域(SPIFFS / LittleFS / FATFS)
    残りの広大なエリア(9MB〜12MB近く)は、ファイルシステムとして使えます。ここに画像データ、音声ファイル、Webサーバー用のHTMLファイル、ログデータなどを大量に保存しておくことができます。

まとめ

今回のトラブルからの教訓:

  • Brownout や TG0WDT 連発 + 発熱 は、コードバグではなく物理的な電源ショート/過負荷を疑う。
  • IOピン(特にIO34等の入力専用ピン)への絶対最大定格(壊れる限界): 3.6 V 超え印加は絶対NG。
  • DevKitの周辺IC(AMS1117やCP2102)はタフ! モジュールを剥がせば復活できる可能性が高い。
  • 裏面GNDサーマルパッドは割り切って、外周ピンだけ手はんだすれば修理は十分可能。

壊れたからと諦めて捨てずに、症状を切り分けてリワークすることで大切な基板を救うことができました。


参考

秋月WROOM-32E-N16: https://akizukidenshi.com/catalog/g/g115675/
アリエクWROOM-32E-N4など: https://ja.aliexpress.com/item/1005008067662376.html?spm=a2g0o.order_list.order_list_main.23.281d585asLhvHb&gatewayAdapt=glo2jpn ESP32-WROOM-32 Datasheet Version 3.8: https://documentation.espressif.com/esp32-wroom-32_datasheet_en.pdf

温湿度センサー AHT20 について思うこと

私見ですが、AHT20には構造的に本質的な問題があります。

湿度が100%に貼りつく

9月と10月の30日間のデータです。上グラフがAHT20が測定した湿度、下グラフが温度です。

ときどき、湿度が100%になり、壊れたかと思うと本来の値に戻ったりします。現時点の46%はBME280で測定している他の場所のデータやエアコンのデータと比べても妥当な値なので、今はAHT20の湿度測定が壊れているとは言えません。乱高下がひどいのは気になります。

上振れしていた3日間はずっと雨が続いていました。その時は湿度100%になっています。その後は晴れたので湿度が下がり、妥当な値になっています。

9月、10月のデータから言えることは、80%を超えると100%に貼りつくと総括できます。

AHT20 30日間のデータ
10月:AHT20 30日間のデータ
9月
9月:AHT20 30日間のデータ

耳学問;ネットを探ってわかったこと

本来ならBosch製の「BME280」ひとつで温度・湿度・気圧をスマートにワンチップで計測できるはずでした。しかし、BoschのBME280は歩留まりの悪さやウェハ価格の高騰、供給不足(これに便乗した偽チップ・リマーク品の多発)により、アマチュア工作市場や格安ボード市場では価格が跳ね上がり、調達が難しくなりました。

そこで中国のモジュールメーカーが編み出した代替案が、「安価で大量供給されているBoschの気圧・温度センサー BMP280」と「格安のAosong(ASAIR)製 温湿度センサー AHT20」を一つの基板(複合モジュール)に強引に乗せてBME280っぽく見せるというアプローチです。

私もおなじプロセスで、¥400程度で購入していたBME280が¥1,000前後になっては手が出せなくなり、AHT20+BMP280 モジュールにたどり着きました。

しかし、その「湿度担当」として選ばれたAHT20側が、高湿度に耐えられない不可逆な欠陥素子(構造的クオリティの低さ)を抱えているため、ユーザー側で不具合が多発している、というのがこのトラブルの構造的な真相みたいです。

ユーザー側の使い方(プログラムや配線)の問題ではなく、市場のコストダウンの歪みによって、「湿度が壊れやすい格安2個イチ素子」を掴まされてしまっている状態と言えます。

報告事例

AHT20のデータシートおよび世界中のフォーラムやIssueを調査したところ、「AHT20の素子の構造的特性に起因する、高湿度環境下での100%付近への張り付き・信号ドリフト問題」について明確に言及している正確なソースが確認できました。

AHT20の製造元であるAosong(ASAIR)社の公式データシートの記載、および実際のユーザーコミュニティでのリアルな問題提起の内容は以下の通りです。

1. 製造元データシートの記載(構造的特性によるドリフト)

Aosong社の公式データシート(PDF)の「3.1 Operating Conditions」および「Figure 4. Working conditions」の項目には、高湿度下における素子の挙動について、構造上の制限として以下の仕様(注意書き)が明記されています。

"Long-term exposure to conditions outside the normal range, especially when the humidity is > 80%, may cause temporary signal drift (drift + 3% RH after 60 hours)." (意訳:通常の範囲を超える環境、特に湿度が80%を超える環境に長期間さらされると、一時的な信号ドリフト(60時間後に+3% RHのドリフト)が発生する可能性があります。)

データシートでは「一時的(temporary)」と表現されており、通常環境に戻せばゆっくり回復するか、規定の加熱乾燥処理(ベーキング)を施すことで校正状態に戻る(Recover)とされています。しかし、これは裏を返せば「水分を吸いすぎることで素子の出力値が上方にズレていく(100%に向かって張り付いていく)特性」が構造上最初から備わっていることを示しています。

2. 世界のコミュニティでの不具合報告(実際のトピック内容)

この特性により、実際にセンサーを運用したユーザーからは「元に戻らない(あるいは極めて戻りにくい)飽和状態」としてトラブルが報告されています。

• Adafruit公式フォーラムでの報告:AHT20 Sensor - Humidity stuck at 100

ユーザー(mbsmith42氏)が「AHT20を数ヶ月運用していたところ、突然湿度が100%から変動しなくなった。横に置いたコントロール用の別の湿度計は100%ではない。プログラムの問題を疑って公式のサンプルコード(complete-test.py)を動かしてみたが、それでもやはり100%固定のまま値が動かない」と報告し、素子自体の異常(または寿命)を疑って対処法を求めています。

• Reddit Arduinoコミュニティでの報告:Restoring AHT20 humidity sensor for high RH

ユーザーが「75〜95%の相対湿度環境で数ヶ月AHT20を使用し続けたところ、値が完全に高い方へとドリフト(飽和)してしまった。データシートに記載されている乾燥リフレッシュ(60〜85℃、5%RH以下で2〜10時間加熱する)を家庭で安全に行うための現実的な方法はないか」と、素子に溜まった水分による飽和からの復旧アイデアを募っています。

構造的問題のまとめ

AHT20のような静電容量式のMEMS温湿度センサーは、ポリマー(感湿膜)が水分を吸収した際の誘電率の変化を測定する構造になっています。

データシートや上記報告が示す通り、「高湿度(80%以上)の環境下において、素子内のポリマーが水分を過剰に抱え込んでしまい、出力が100%側へドリフトしてロックされる(乾燥環境に戻しても簡単には水分が抜けない)」という物理的・構造的な性質があり、これが「100%不具合」の正体だと考えます。

この不具合は、データシートにあるような「オーブン等での加熱乾燥(ベーキング)」によって一時的に復旧する可能性はあります。冒頭に載せた30日間のデータがまさにこの状況を表しています。多湿が続くと100%に貼りつき、乾燥すると戻ります。 しかし、根本が素子の構造的限界であるため、同じ環境で使えば再発します。

BME280が数ヶ月おきに値が固まる不具合と回避策

このブログで扱うBME280はAmazonやAliexpress購入なので、正規のBosch製BME280なのか、互換品や偽物なのかの真偽は不明です。ただし、ICチップの刻印を見ると、正規品と考えられるAdafruit BME280と同じ形式なのはわかります。

Amazon購入/ Adafruit BME280
Amazon購入/ Adafruit BME280

ESP8266やESP32とBME280で自作した温・湿度、気圧測定器をずっと運用していますが、 数か月間はきわめて順調に動作していたにもかかわらず、ある日突然データ更新が停止し、マイコン本体を再起動しても一向に直らないという現象に長年悩まされてきました。

本記事では、この不具合の現象・原因分析から、海外フォーラムの技術情報、そして最終的に到達した「GPIO給電による自動電源再投入(ハードウェアリセット)」の具体的な対策実装までを解説します。


1. 遭遇したトラブル現象:地球上に存在しない「646hPa」での完全固着

自作の温湿度・気圧センサ(ESP8266, ESP32 / BME280)を色々な部屋に設置し、Home AssistantやBlynkへ定期送信するシステムを数年来、運用しています。これらは1個体を除き、どれも数か月から半年ほどのスパンで、突然以下のような異常値を出力したまま動かなくなる症状が発生します。

  • 固定される数値例:
    • 温度: 22.1 ℃
    • 湿度: 58.45 %
    • 気圧: 646.00 hPa

海面気圧の平均値(約1013hPa)に対して「646hPa」という数値は、台風の中心気圧どころか台風の上空数千メートルに相当する物理的にあり得ない値です。

最初の頃はBME280が壊れたと思い、新しい個体に交換していました。しかし、何度か経験するうちに、故障ではなく症状だと気づきました。

不思議な挙動と迷宮入り

このトラブルの真の厄介さは、通常のソフトリセットで復旧しない点にありました。

  1. マイコンを再起動しても直らない: ESP8266/ESP32のRESETボタンを押したり、プログラムから ESP.restart() を実行したりしても、再起動後に再び「22.1℃ / 58% / 646hPa」がそのまま出力され続けます。
  2. センサモジュールを新品に交換しても再発する: 個体不良を疑い、AmazonやAliExpressで新品のBME280基板を購入して交換しても、数か月経つと同じような数値で固着します。
  3. 何度かのBME280電源抜き差しでのみ復旧する: 経験値として、唯一の復旧方法は、BME280に接続されているVCC(電源線)を物理的に抜き、数秒置いて挿し直す(あるいは電源ラインのジャンパピンを何度か着脱する)ことだけでした。ただし、一度で復旧するのではなく、何度かやると気づいたら直っていたという感じです。

そこで、以下のソフトウェア対策をしましたが、効果はありませんでした。

  • タイムアウト処理: isMeasuring() の完了待ちループに時間制限(200msなど)を設け、万が一I2Cがフリーズしても無限ループ化(WDTリセット)するのを防ぎます。
  • ピンの強制リセット: 通信異常やタイムアウトを検知した際、GPIOピンを一度INPUT(解放)にしてバスのロック状態を強制解除し、Wire.begin() で再初期化をかけます。

2. 原因の探求:データシートの仕様と世界中のトラブル報告

「なぜマイコンをリセットしてもセンサが直らないのか?」を突き詰めるため、Boschのデータシートと世界中の技術フォーラムを徹底的に調査しました。

マイコンリセットで直らない理由

Bosch公式のBME280/BME280データシートを確認すると、本センサのパワーオンリセット(POR)はVDD(電源電圧)の再投入、またはI2C経由での特定リセットレジスタ(0xB6)への書き込みによってのみ実行されます。

マイコン(ESP8266/ESP32)の電源ラインとセンサのVCCが共通の3.3Vレールに常時接続されている場合、マイコンがリセットされてもセンサ本体の電源(VDD)は絶たれません。そのため、センサ内部のステートマシンやADC・I2Cバスインターフェースが異常状態(ロックアップ)に陥ると、マイコンが何度起動し直してもセンサはロック状態を保持し続けてしまいます。

海外コミュニティで見つかった「同一の症例」

さらに調査を進めると、Particle、Raspberry Pi、Pimoroni、Home Assistantコミュニティなどの海外フォーラムにおいて、似たような症状に苦しむユーザーの報告が多数発見されました。

No. 事例(リンク) 温度 (℃) 気圧 (hPa) 湿度 (%) 原文で確認できる復旧操作・結果
1 Particle Community:BME280 sensor problem −140(例示値) 記載なし 記載なし マイコンのResetでは改善せず、センサーの電源を抜き差しすると復旧。 Particle
2 Pimoroni:BME280 Sensor gives stuck readings 22.17 678.38 65.92 センサー単体化、I²Cスロット変更、IDEケーブル取り外し、Pythonパッケージ再インストールを試したが改善せず。復旧の記載なし。 Pimoroni Buccaneers
3 ESPHome Issue \#3383:BME280 suddenly starts reporting pressures 記載なし 約700~760(通常約1005~1025) 記載なし ソフトウェアリセットを追加すると問題が解消したようだと報告。別の投稿者 markusg1234 も修正で直ったと報告。 GitHub GitHub
4 ESPHome Core Issue \#87:Bosch BME280 reads strange values 記載なし 約40 hPa低下、または1224.7 0%と100%を頻繁に行き来 復旧確認できない。 GitHub
5 Bosch Sensortec:Humidity stuck at 100% 温度にも問題(具体値なし) 記載なし 100%が数日継続 電源再投入では復旧しなかった。 BME280
6 Arduino Forum:Anomalia sensore BME280 約169、約92、0 負の値、0、後に正常らしい値 100、0 接触問題。 Arduino Forum Arduino Forum
7 Sensor.Community:Temperature shows −141.63 °C −141.63、または0.00 記載なし 記載なし 再起動だけでは改善せず、電源を抜いて再接続した後に再起動すると復旧。数か月後に再発した。 Forum Sensor.Community

特にPimoroniのコミュニティフォーラムでは、複数のユーザーから以下の報告が上がっていました。

報告事例 固着時の温度 固着時の気圧 固着時の湿度
スレ主 22.17 ℃ 678.38 hPa 65.92 %
別の投稿者 22.10 ℃ 644.67 hPa 65.03 %
我が測定値 22.10 ℃ 646.00 hPa 58.45 %

この事例は私の場合と数値がほぼ一致しています。BME280/BME280内部で読取エラーや未初期化状態(レジスタ値 0x80000)が発生した際、チップ固有の校正計算式を通すことで一律「22℃前後 / 640〜670hPa台」の値として算出されてしまうという、IC内部のロジック・設計に起因する既知の現象との裏づけだと言えます。

この件は重要なので掘り下げておきます。

  1. ICの未初期化・エラー時のデフォルトレジスタ値(0x80000)
    BME280/BMP280のデータシートおよびドライバの仕様上、BME280/BMP280は未初期化状態や測定未完了・スキップの際、データレジスタ(生のADC値)に初期値/リセット値として 0x80000(湿度は 0x8000) を保持・出力します。
  2. チップ固有の校正パラメータによる計算結果
    センサーライブラリ(BME280_MOD-1022 や各種Pythonライブラリ等)は、I2Cで読み出した生データが 0x80000 であってもエラーと判定せず、そのままチップ内部ROMに焼き付けられている「個体別の校正係数(トリミングパラメータ)」を適用して温度・気圧を算出します。
  3. 計算処理
    この 0x80000 という固定値を校正計算式に通すと、一般的な補正係数の範囲では必ず「22℃前後 / 640〜670hPa台」という数値として算出 されます。(※気圧640〜670hPa台は、地上の標準気圧約1013hPaに対して物理的にあり得ない異常値ですが、計算式の上ではこの範囲に収まります 。)

また、固着時にはI2CバスのSDAまたはSCLがLow固定になり、バスハングを引き起こすケースがあることも判明しました。
SDA: https://community.bosch-sensortec.com/mems-sensors-forum-jrmujtaw/post/bme280-scl-pulled-low-iY823TJH8V99QYO?utm_source=chatgpt.com
SCL: https://forums.raspberrypi.com/viewtopic.php?start=25&t=306875&utm_source=chatgpt.com

  1. I2C通信の最中にノイズやタイミングの乱れ、測定の割り込み等が重なると、スレーブ側(センサIC)がデータ送信の途中でストップし、SDAまたはSCLラインをLowに引き下げたまま開放しない「バスハング」状態に陥ります。 マイコン側(ESP8266/ESP32のWireライブラリ等)に自動バスリカバリーやタイムアウト処理が組み込まれていない場合、以降の通信が一切成立しなくなります。
  2. 通信が途絶えた結果、ライブラリは「前回正常に読み取ったバッファ値」や「エラー時の固定値」を返し続けるため、出力数値が固着します。
  3. マイコン本体をソフトリセットしてもセンサ側の電源(VDD)は遮断されないため、センサICの内部フリーズやI2CバスのLow固定は解除されず、センサの電源(VCC)を物理的に切断・再投入しない限り復旧しない状態が保持されます。

3. 解決策:GPIO給電による自動パワーサイクル(ハードウェアリセット)

ソフトリセットコマンド(0xB6)の書き込みすら通信ハングで受け付けなくなるこの不具合に対し、海外の有力な解決策として提示されていたのが「センサの電源(VCC)をマイコンのGPIOから供給し、異常検知時にGPIOをOFF/ONして物理的に電源を入れ直す」手法です。

対策事例

① Particle フォーラム(GPIO給電による電源再投入対策)

URL: https://community.particle.io/t/bme280-sensor-problem/49627
概要: BME280が異常値を返し始めた際、マイコンのリセットでは復旧しないため、センサのVCC電源をGPIOピンから供給し、ソフトウェア処理で電源を一度落として入れ直すリセット手法で回避・解決した事例です。

② Raspberry Pi フォーラム(電源再投入による復旧策)

URL: https://www.raspberrypi.org/forums/viewtopic.php?t=240098
概要: BMP280/BME280が固着した際、ソフト制御のみでは解除できず、モジュールの物理的な電源を切って入れ直す方針が採られたスレッドです。

ハードウェア設計の妥当性

「GPIOからセンサの電源を取って大丈夫か?」という疑問が生じますが、定格を確認すると問題ありません。

  • ESP8266/ESP32のGPIO電流制限: ピン1本あたり最大12mA程度まで供給可能。
  • BME280/BME280の消費電流: 測定時でも約1mA(待機時は数µA)。

BME280/BME280単体の消費電流はわずか1mA程度のため、GPIOピンから直接VCCを給電しても何ら問題はありません。

回り込み電流対策の重要性

単純にGPIOをLOWにしてVCCを切るだけでは、I2Cライン(SDA/SCL)に引っ張られている3.3Vプルアップ抵抗経由でセンサ内部へ微弱な漏れ電流(回り込み電流)が流れ続け、センサが完全に放電・リセットされない場合があります。

そのため、電源OFFのシーケンスではSDAおよびSCLのピン出力も一時的にLOWまたはINPUT(Hi-Z)に変更して寄生給電を断ち切ることが不可欠です。


4. Arduino IDEにおける具体的な実装例(スケッチ)

以下は、GPIOでセンサ電源を制御し、異常値を検出した際に自動的にVCCをリセット・再初期化するArduinoスケッチの実装パターンです。

#include <ESP8266WiFi.h>
#include <Wire.h>
#include <BME280_MOD-1022.h>

// ピン定義 (環境に合わせて変更可能)
const int BME_PWR_PIN = 14; // BME280のVCCを接続するGPIO (例: GPIO14/D5)
const int SDA_PIN     = 4;  // I2C SDA (GPIO4/D2)
const int SCL_PIN     = 5;  // I2C SCL (GPIO5/D1)

// パワーサイクル設定
const unsigned long BME_PWR_OFF_MS = 1000; // 電源OFF保持時間 (1000ms)
const unsigned long BME_PWR_ON_MS  = 100;  // 電源ON後の安定待ち時間 (100ms)
const int MAX_RETRIES              = 5;    // 最大リトライ回数 (1回で不完全な場合に対応)

// 固着監視用の設定 (1秒毎読取りで約2分間=120回変化がなければ固着と判定)
const int STUCK_CHECK_THRESHOLD    = 120;  
int sameValueCount                 = 0;
int powerCycleCount                = 0;    // 電源リセット累積実行回数

// 前回測定値の保持
float prevTemp = -999.0;
float prevHumi = -999.0;
float prevPres = -999.0;

// 現在の測定値
float temperature = 0.0;
float humidity    = 0.0;
float pressure    = 0.0;

// BME280/BMP280の物理電源リセット処理
void powerCycleBME280() {
  Serial.println("[BME280] 電源リセット(パワーサイクル)を実行します...");

  // 1. I2Cバス経由の寄生給電(回り込み電流)を防ぐためピンをLOWに設定
  pinMode(SDA_PIN, OUTPUT);
  pinMode(SCL_PIN, OUTPUT);
  digitalWrite(SDA_PIN, LOW);
  digitalWrite(SCL_PIN, LOW);

  // 2. センサのVCC電源を遮断して放電待ち
  digitalWrite(BME_PWR_PIN, LOW);
  delay(BME_PWR_OFF_MS);

  // 3. 電源を再投入して安定化待ち
  digitalWrite(BME_PWR_PIN, HIGH);
  delay(BME_PWR_ON_MS);

  // 4. I2Cバス再開 & センサ再初期化
  Wire.begin(SDA_PIN, SCL_PIN);
  delay(50);

  BME280.readCompensationParams();
  BME280.writeStandbyTime(tsb_0p5ms);
  BME280.writeFilterCoefficient(fc_16);
  BME280.writeOversamplingPressure(os16x);
  BME280.writeOversamplingTemperature(os2x);
  BME280.writeOversamplingHumidity(os1x);
  BME280.writeMode(smNormal);

  powerCycleCount++;
  Serial.printf("[BME280] 電源リセット完了(累積実行回数: %d)
", powerCycleCount);
}

// センサ読取りおよび二重異常検出 (気圧範囲外 & 長時間同値固着)
bool readAndValidateSensor() {
  while (BME280.isMeasuring()) {
    yield();
  }

  BME280.readMeasurements();
  float t = BME280.getTemperatureMostAccurate();
  float h = BME280.getHumidityMostAccurate();
  float p = BME280.getPressureMostAccurate();

  // 異常判定1: 気圧が標準数値範囲(800〜1100hPa)外
  if (p < 800.0 || p > 1100.0) {
    Serial.printf("[BME280] 異常検知 (気圧範囲外): T=%.2f, H=%.2f, P=%.2f
", t, h, p);
    sameValueCount = 0;
    return false;
  }

  // 異常判定2: 全測定値が長期間完全に同一(数値固着の検知)
  if (t == prevTemp && h == prevHumi && p == prevPres) {
    sameValueCount++;
    if (sameValueCount >= STUCK_CHECK_THRESHOLD) {
      Serial.printf("[BME280] 異常検知 (数値固着): %d回連続で同値
", sameValueCount);
      sameValueCount = 0;
      return false;
    }
  } else {
    sameValueCount = 0;
  }

  // 正常読取り時の保持値更新
  prevTemp = t;
  prevHumi = h;
  prevPres = p;

  temperature = t;
  humidity    = h;
  pressure    = p;
  return true;
}

// 復旧確認付きの読取り処理 (最大5回のリトライ)
bool readSensorWithRecovery() {
  if (readAndValidateSensor()) {
    return true;
  }

  // 異常検知時: 最大5回まで電源リセットを繰り返し復旧を試みる
  for (int retry = 1; retry <= MAX_RETRIES; retry++) {
    Serial.printf("[BME280] 電源リセット復旧試行 %d/%d 回目
", retry, MAX_RETRIES);
    powerCycleBME280();

    // リセット直後の正常読取り確認
    if (readAndValidateSensor()) {
      Serial.println("[BME280] 正常に復旧しました。");
      return true;
    }
  }

  Serial.println("[ERROR] 最大リトライ回数を超えましたが復旧しませんでした。");
  return false;
}

void setup() {
  Serial.begin(115200);

  // センサ電源ピン初期化とON
  pinMode(BME_PWR_PIN, OUTPUT);
  digitalWrite(BME_PWR_PIN, HIGH);
  delay(BME_PWR_ON_MS);

  Wire.begin(SDA_PIN, SCL_PIN);
  BME280.readCompensationParams();
  // (各種設定...)
}

void loop() {
  static unsigned long lastRead = 0;
  if (millis() - lastRead > 1000) { // 1秒毎の判定
    lastRead = millis();

    if (readSensorWithRecovery()) {
      // 正常時の処理 (例: Home Assistant / Blynk / シリアル出力等)
      // Serial.printf("温度: %.2f ℃, 湿度: %.2f %%, 気圧: %.2f hPa
", temperature, humidity, pressure);
    }
  }
}

この「異常値検出 + GPIO電源カット + 回り込み防止 + 再起動」のロジックを組み込むことで、データ固着が起きたときには自動でBME280電源のON/OFFを行います。効果は半年とか、1年経たなければ見えてきません。


5. 【補足】BME280の温度が4〜5℃高く表示されるトラブルの罠

BME280/BME280の運用において、もう一つよくハマるのが「BME280の温度表示が室温計より4〜5℃高く出力される」という現象です。

原因:BME280の温度センサは「気圧補正用の内部温度」

卓上温度計やエアコンのセンサー値(例: 22.5℃)と比べた際、BME280が「27.0℃」といった高い値を指すため「壊れているのでは?」と疑いたくなります。

しかし、BoschのBME280データシートには以下の旨が記載されています。

"The integrated temperature sensor is optimized for lowest noise and high resolution. Its output is used for temperature compensation of the pressure sensor and can also be used for reading ambient temperature."

BME280の温度センサは本来気圧測定のためのダイ(ICチップ)温度補正用であり、基板上のESP32/ESP8266の自己発熱やレギュレータの熱伝導をダイレクトに受けてしまいます。

対処法

ESPHomeやArduino環境でBME280を気温計として使用する場合は、ESP8266/ESP32をDeep Sleepモードで間欠に起動して発熱を抑えるか、以下のように実験によって求めた固定オフセット(-4.0℃ 前後)を当てて補正するのが標準的なアプローチとなります。

ESPHomeでのYAML設定例:

sensor:
  - platform: BME280_i2c
    i2c_id: bus_a
    address: 0x77
    update_interval: 60s
    temperature:
      name: "BME280 Temperature"
      filters:
        - offset: -4.0   # 内部自己発熱・個体差に対する補正
    pressure:
      name: "BME280 Pressure"

6. まとめ

BME280およびBME280は非常に高精度で信頼性の高いセンサですが、長期運用において以下の罠が存在します。

  1. 数か月おきのフリーズ(気圧646hPa固着):
    • マイコンリセットでは解除されないため、GPIO給電による電源物理リセット回路・制御を組むのが最も効果的な回避策。
  2. 温度測定値の高め出力:
    • 気圧補正用のダイ内部温度であるため、オフセット補正(-4.0℃ 前後)またはDeep Sleep との組み合わせが必須。

センサーのデータシートやICの物理的挙動を正しく理解し、あらかじめトラブルを見越したハードウェア・ソフトウェア設計を行っておくことで、安定したスマートホーム環境・IoTノードを維持することができます。

3線式DCファンPWM駆動のステップ応答測定

要点 〜UltraScopeの量子化、SCPI RAWモード、F/V変換〜

  • ESP32でPWM駆動+PI制御した3線式ファンについて、回転数の指示を 1000↔2000rpm に切り替えたときのステップ応答(過渡特性)を測りました。
  • UltraScope で取得したCSV(約1,200点=2ms刻み)で解析すると、パルス間隔が14msか16msに丸められ、回転数が 1875rpm と 2143rpm でバタツキます。
  • DS1054Z の内部メモリを SCPI の RAW モードで全点読み出す(2ch・600k点で20µs刻み)と、滑らかな応答が得られました。低→高は τ63=0.23秒、高→低は τ63=0.50秒と言った時定数の測定も可能です。
  • 555のワンショット+LPFで作ったF/V変換では、応答をオシロで直接見られます。ただしLPFの遅れがあり、アンダーシュートの大きさがTACH解析と合いません。
  • 定量評価はRAW方式、F/Vは傾向の確認用、こういった目的に応じた使い分けが適しています。

1. 背景と目的

前報で、ESP32による3線式DCファンのPWM駆動回路にPI制御を入れた実験を行いました。このステップ応答を視覚的に観察したり、定量的に評価しようと思います。

しかし、現在の回路には F/V 変換器(回転数を即座にアナログ電圧として出力する回路)が存在しないため、オシロスコープで回転数の変化をリアルタイムに電圧波形として直接観測することはできません。

そこで、オシロスコープで TACH(タコ)パルスの波形を長周期記録し、オフラインでパルスの時間差(周期)から回転数(RPM)を逆算するアプローチを採ることにしました。

思い付きでやっている実験なので、やはりステップ応答をオシロで観察したくなりました。そこで、F/V変換を追加して結果を比べます。

2. 測定の仕組み

flowchart LR

    ESP32["ESP32"]
    TACH["ファンのTACH"]
    FV["555のF/V出力"]
    CH1["オシロ CH1"]
    CH2["オシロ CH2<br/>(トリガ)"]
    CH4["オシロ CH4"]
    SCOPE["オシロ<br/>(メモリ)"]
    PC["PC<br/>Python: CSV → 回転数 → 指標"]

    ESP32 -- "GPIO27: 同期信号" --> CH2
    TACH --> CH1
    FV --> CH4

    CH1 --> SCOPE
    CH2 --> SCOPE
    CH4 --> SCOPE

    SCOPE -- "SCPI / USB" --> PC
  • ESP32から指示回転数の切り替えと同時にトリガ信号(GPIO27)を出力し、オシロ(DS1054Z)で TACH パルス列を記録します。
  • 記録されたデータから、時々刻々と変化するTACH パルス列(μ秒)を回転数に換算します。
    • CSVデータの取得にはオシロ(DS1054z)を使います。つまり、オシロはデータ表示ではなく、データのメモリ装置です。
    • データの掃き出し方法には2通り(UltraScope; 簡単, SPCI+Python; 高解像度)があります。
    • 解析処理はエクセルかPython スクリプトを使います。

3. UltraScope(画面データ1,200点)によるTACH パルス列取得

データ書き出しの手軽な手段として、まずは RIGOL 純正のデータロガーソフトUltraScopeの CSV 保存機能(Save Channel)を使い測定しました。

オシロスコープの画面時間軸を 200ms/div(画面全幅12divで2.4秒)に設定し、UltraScope 経由で取得したデータを回転数に換算したグラフを下記に載せます。

特性は滑らかな変化を描くどころか、1875rpm と 2143rpm の2つの値を交互に激しく往復する重度のバタツキ(ガタガタの波形) となっています。

UltraScope, 1000 → 2000rpm 回転数グラフ

1000 to 2000
UltraScope, 1000 → 2000 rpm
UltraScope, 2000 → 1000rpm 回転数グラフ
2000 to 1000
UltraScope, 2000 to 1000 rpm

ガタガタの原因は、UltraScope の CSV 出力が「オシロスコープのディスプレイ画面描画用データ(画面12div × 約100点/div = 約1,200点)」をベースに抽出している点にあります。

時間軸 200ms/div のとき、データ点間の時間分解能(サンプル間隔)は以下のように固定されます:

\[\text{サンプル間隔} = \frac{200\,\text{ms/div} \times 12\,\text{div}}{1,200\,\text{点}} = 2\,\text{ms}\]

約2000rpm(1回転あたり2パルスのためパルス周期は約15ms)で回転している場合、2ms刻みのデータではパルス間隔が 14ms または 16ms のどちらかに丸め込まれて(量子化されて)しまいます。 これを回転数に換算すると以下のようになります:

$$\cfrac{60}{2 \times 0.014\,\text{s}} = 2143\,\text{rpm}\\ \cfrac{60}{2 \times 0.016\,\text{s}} = 1875\,\text{rpm}$$

UltraScopeの1200分割では、本来15ms付近にあるはずの連続的な周期変化が 2ms の量子化幅によって粗く刻まれ、ステップ応答の立ち上がりや収束挙動が量子化ノイズに完全に埋もれてしまうという限界が判明しました。


4. SCPI+Python(RAWモード)によるTACH パルス列取得

画面描画用データ(NORMALモード)の1,200点制限に縛られることなく、オシロメモリの持つ60000点のデータを取得します。このため、参照:ブログの「Rigol DS1054Z で波形をCSV出力する」で紹介した、Python と PyVISA を用いてオシロスコープから直接内部メモリを読み出すスクリプト(capture_ds1054z.py)を作成しました。

capture_ds1054z.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DS1054Z の取り込みメモリを SCPI (USB/LAN) で全点(RAW)読み出して CSV に保存し、そのままステップ応答を解析する。

使い方 (オシロは操作しない。ESP32 とファンは動かしたまま)
  1) ESP32 のシリアルで  s + Enter  を1回送る        (回転数の指示が 1000 <-> 2000rpm を4秒ごとに交互に切り替わり続ける)
  2) 好きなタイミングで PC から  python capture_ds1054z.py  を実行する
       -> 本体を設定して待機にし、次の 低->高ステップ (最長8秒後) で12秒分を取り込み、
          全点を読み出して capture_日時.csv に保存し、続けて step_response.py で解析する
  オシロ本体のボタンは押さない。プローブ接続は CH1=TACH、CH2=GPIO27 (SYNC)。

  python capture_ds1054z.py --ch 1,2,3      # VD(CH3)も読む場合 (3chだと 300k点=25kSa/s)
  python capture_ds1054z.py --keep-settings # 本体の設定をスクリプトで変えない (本体を手で設定済みのとき)
  python capture_ds1054z.py --no-analyze    # 解析せず CSV の保存だけ
  python capture_ds1054z.py --resource TCPIP0::192.168.1.30::INSTR   # LANの場合 (USBは自動選択)

スクリプトが本体に設定する内容 (--keep-settings で無効)
  使う CH を ON (DC結合、--vdiv V/div)、使わない CH を OFF、時間軸 1s/div、トリガ位置を左から2目盛り、
  メモリ深度 (有効CH数から自動: 1ch 1.2M / 2ch 600k / 3-4ch 300k)、エッジトリガ CH2 立ち上がり 1.5V
  12秒 (1s/div) で 2ch・600k点 なら 50kSa/s = 20µs刻み (TACH のパルス間隔 12〜75ms に対して十分)

NORMAL モードは画面の約1200点しか返さないので、STOP 後に RAW モードで内部メモリを分割して読む。
出力 CSV: Time(s), CH1(V), CH2(V), ...   t=0 はトリガ点
"""
import argparse
import datetime
import os
import subprocess
import sys
import time

import numpy as np
import pandas as pd
import pyvisa

MDEP_BY_CH = {1: 1200000, 2: 600000, 3: 300000, 4: 300000}


def pick_resource(rm, given):
    if given:
        return given
    res = rm.list_resources()
    cand = [r for r in res if "0x1AB1" in r.upper() or "DS1" in r.upper()]
    if not cand:
        sys.exit("エラー: DS1054Z が見つかりません。USBの接続と NI-VISA を確認してください。見つかった資源: %s" % (res,))
    return cand[0]


def apply_setup(scope, chans, vdiv, timebase, pre_div, mdep, level):
    print("本体を設定します...")
    scope.write(":STOP")
    for ch in (1, 2, 3, 4):
        if ch in chans:
            scope.write(f":CHAN{ch}:DISP ON")
            scope.write(f":CHAN{ch}:COUP DC")
            scope.write(f":CHAN{ch}:SCAL {vdiv}")
            scope.write(f":CHAN{ch}:OFFS 0")
        else:
            scope.write(f":CHAN{ch}:DISP OFF")
    if mdep is None:
        mdep = MDEP_BY_CH[min(len(chans), 4)]
    scope.write(f":TIM:MAIN:SCAL {timebase}")
    # 画面中央の時刻 = トリガから +offset。トリガを左から pre_div 目盛りに置く: offset = (6 - pre_div) * timebase
    scope.write(f":TIM:MAIN:OFFS {(6 - pre_div) * timebase}")
    scope.write(f":ACQ:MDEP {mdep}")
    got = scope.query(":ACQ:MDEP?").strip()
    if got.upper() != "AUTO" and int(float(got)) != mdep:
        print(f"  警告: メモリ深度が {mdep} になりません (本体の値: {got})。選べる値はチャンネル数で変わります。")
    scope.write(":TRIG:MODE EDGE")
    scope.write(":TRIG:EDGE:SOUR CHAN2")
    scope.write(":TRIG:EDGE:SLOP POS")
    scope.write(f":TRIG:EDGE:LEV {level}")
    scope.query("*OPC?")
    print(f"  CH{','.join(map(str, chans))} ON ({vdiv} V/div)、時間軸 {timebase}s/div、トリガ CH2 立ち上がり {level}V、メモリ深度 {got}")


def wait_for_trigger(scope, timeout_s):
    scope.write(":SING")
    # 1) SINGLE に入った (STOP 以外になった) ことを確認する。入らなければ1回だけ再送
    entered = False
    st = "STOP"
    for _ in range(2):
        t0 = time.time()
        while time.time() - t0 < 3.0:
            st = scope.query(":TRIG:STAT?").strip()
            if st != "STOP":
                entered = True
                break
            time.sleep(0.1)
        if entered:
            break
        scope.write(":SING")
    if not entered:
        sys.exit("エラー: 本体が SINGLE (待機) になりません。本体が STOP のままです。")
    print(f"待機中 (本体: {st})。次の 低->高ステップ (最長8秒) でトリガします...")
    # 2) トリガして取り込みが終わる (STOP) のを待つ
    t0 = time.time()
    while time.time() - t0 < timeout_s:
        st = scope.query(":TRIG:STAT?").strip()
        if st == "STOP":
            time.sleep(0.5)       # 取り込み完了直後はメモリが読めないことがあるので少し待つ
            print("取り込み完了。読み出します...")
            return
        time.sleep(0.2)
    sys.exit("エラー: トリガが掛かりませんでした。ESP32 で試験が動いていない可能性があります "
             "(シリアルで s + Enter)。動いている場合は GPIO27 の接続と CH2 の V/div を確認してください。")


def memory_depth(scope):
    s = scope.query(":ACQ:MDEP?").strip()
    try:
        return int(float(s))
    except ValueError:
        # AUTO の場合は サンプルレート × 12div × 時間軸 から求める
        srate = float(scope.query(":ACQ:SRAT?"))
        scale = float(scope.query(":TIM:MAIN:SCAL?"))
        n = int(round(srate * scale * 12))
        print(f"  メモリ深度 AUTO -> 約 {n} 点 (サンプルレート {srate:g} Sa/s)")
        return n


def read_channel(scope, ch, n_total, chunk):
    scope.write(f":WAV:SOUR CHAN{ch}")
    scope.write(":WAV:MODE RAW")
    scope.write(":WAV:FORM BYTE")
    scope.write(":WAV:STAR 1")
    scope.write(f":WAV:STOP {min(chunk, n_total)}")
    pre = [float(x) for x in scope.query(":WAV:PRE?").strip().split(",")]
    xinc, xorg, xref, yinc, yorg, yref = pre[4], pre[5], pre[6], pre[7], pre[8], pre[9]
    codes = np.empty(n_total, dtype=np.uint8)
    pos = 0
    while pos < n_total:
        stop = min(pos + chunk, n_total)
        scope.write(f":WAV:STAR {pos + 1}")
        scope.write(f":WAV:STOP {stop}")
        data = scope.query_binary_values(":WAV:DATA?", datatype="B", container=np.array)
        got = len(data)
        if got == 0:
            st = scope.query(":TRIG:STAT?").strip()
            sys.exit(f"エラー: CH{ch} のデータが空です (点 {pos + 1}〜{stop})。本体の状態: {st}。"
                     "取り込みが完了(STOP)していない可能性があります。")
        codes[pos:pos + got] = data
        pos += got
        print(f"  CH{ch}: {pos}/{n_total} 点", end="\r")
    print(f"  CH{ch}: {n_total} 点 読み出し完了        ")
    volts = (codes.astype(np.float64) - yorg - yref) * yinc
    t = (np.arange(n_total) - xref) * xinc + xorg
    return t, volts, xinc


def main():
    ap = argparse.ArgumentParser(description="DS1054Z のメモリを RAW で全点読み出して CSV に保存し、解析する")
    ap.add_argument("--resource", help="VISA資源名 (省略時は USB の DS1054Z を自動選択)")
    ap.add_argument("--ch", default="1,2", help="使うチャネル (既定 1,2 = TACH, SYNC。VDも読むなら 1,2,3)")
    ap.add_argument("--out", help="出力CSV (既定: capture_日時.csv)")
    ap.add_argument("--keep-settings", action="store_true", help="本体の設定を変えない")
    ap.add_argument("--no-wait", action="store_true", help="待機せず、STOP 済みのメモリを読む")
    ap.add_argument("--no-analyze", action="store_true", help="取り込み後に step_response.py を実行しない")
    ap.add_argument("--vdiv", type=float, default=2.0, help="各chの縦軸 [V/div] (既定 2)")
    ap.add_argument("--timebase", type=float, default=1.0, help="時間軸 [s/div] (既定 1)")
    ap.add_argument("--pre-div", type=float, default=2.0, help="トリガ位置 (左から何目盛り, 既定 2)")
    ap.add_argument("--mdep", type=int, default=None, help="メモリ深度 (省略時は有効チャンネル数から自動)")
    ap.add_argument("--level", type=float, default=1.5, help="トリガレベル [V] (既定 1.5)")
    ap.add_argument("--wait-s", type=float, default=30.0, help="トリガ待ちの最大時間 [s] (既定 30)")
    ap.add_argument("--chunk", type=int, default=100000, help="1回に読む点数 (BYTE形式の上限は 250000)")
    args = ap.parse_args()

    chans = sorted(int(c) for c in args.ch.split(","))
    if 2 not in chans:
        sys.exit("エラー: トリガの同期信号 (CH2) が必要です。--ch に 2 を含めてください。")
    out = args.out or datetime.datetime.now().strftime("capture_%Y%m%d_%H%M%S.csv")

    rm = pyvisa.ResourceManager()
    res = pick_resource(rm, args.resource)
    print("接続:", res)
    scope = rm.open_resource(res)
    scope.timeout = 30000
    scope.chunk_size = 1024 * 1024
    try:
        scope.clear()                      # 前回の読み残しがあれば捨てる
    except Exception:  # noqa: BLE001
        pass
    print("機器:", scope.query("*IDN?").strip())

    try:
        if not args.keep_settings:
            apply_setup(scope, chans, args.vdiv, args.timebase, args.pre_div, args.mdep, args.level)
        if args.no_wait:
            scope.write(":STOP")
        else:
            wait_for_trigger(scope, args.wait_s)

        n_total = memory_depth(scope)
        series = {}
        t_ref = None
        for ch in chans:
            t, v, xinc = read_channel(scope, ch, n_total, args.chunk)
            if t_ref is None:
                t_ref = t
                print(f"サンプル間隔 {xinc * 1e6:.2f} µs ({1.0 / xinc / 1e3:.1f} kSa/s)、範囲 {t[0]:.3f}〜{t[-1]:.3f} s")
            series[f"CH{ch}(V)"] = v
        df = pd.DataFrame({"Time(s)": t_ref, **series})
        df.to_csv(out, index=False, float_format="%.8g")
        print(f"保存: {out}  ({len(df)} 行)")
    finally:
        try:
            scope.write(":RUN")            # 本体を通常の取り込みに戻す
            scope.close()
        except Exception:  # noqa: BLE001
            pass

    if not args.no_analyze:
        script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "step_response.py")
        if os.path.exists(script):
            print("\n--- ステップ応答の解析 ---")
            subprocess.run([sys.executable, script, out], check=False)
        else:
            print("step_response.py が同じフォルダに無いので解析は省略しました。")


if __name__ == "__main__":
    main()

回転数の変更(1000 → 2000 → 1000rpm)を一度に取り込めるように、オシロ側で時間軸1s/div、トリガ位置を左から2目盛り、メモリ深度600k、CH2立ち上がり1.5V に設定しておきます。

スクリプトは、測定停止(STOP)状態において、SCPI コマンド :WAV:MODE RAW を送ることで、DS1054Z 内部の大容量サンプルメモリ(RAWデータ)をブロック分割転送で直接取得できます。

PS C:\Users\[User]\ドキュメント\FanPwm_Step> python capture_ds1054z.py
接続: USB0::0x1AB1::0x04CE::DS1ZAxxxxxxxxxx::INSTR
機器: RIGOL TECHNOLOGIES,DS1104Z,DS1ZAxxxxxxxxxx,00.04.05.SP2
本体を設定します...
  CH1,2 ON (2.0 V/div)、時間軸 1.0s/div、トリガ CH2 立ち上がり 1.5V、メモリ深度 600000
待機中 (本体: RUN)。次の 低->高ステップ (最長8秒) でトリガします...
取り込み完了。読み出します...
  CH1: 600000 点 読み出し完了
サンプル間隔 20.00 µs (50.0 kSa/s)、範囲 -2.000〜10.000 s
  CH2: 600000 点 読み出し完了
保存: capture_20261024_155906.csv  (600000 行)

RAWモードの要点

  • NORMALモードは、画面に表示される約1,200点だけを返します。
  • RAWモードは、STOP後に内部メモリの全点を読めます。1回の読み出しの上限(BYTE形式)は250,000点なので、分割して読みます。
  • 12秒を600k点で取ると、50kSa/s=20µs刻み。(UltraScopeの2msの100倍細かい。)2000rpm(周期15ms)のパルス間隔の誤差は、最大でも ±0.13%(約2.7rpm)です。
  • DS1054zの仕様上、メモリ深度の選択肢は有効なチャンネル数で変わります。 2ch(CH1+CH2)でのメモリ深度は 6k / 60k / 600k / 6M / 12M です。同期信号のCH2が必要なので、600kを使いました。

ESP32のログ(抜粋)

16:36:52.425 ->   SET RPM= 2000 (TEST) 1777mV     
16:36:52.657    ->   CMD= 2000rpm(TEST)  DUTY= 69.2% TACH=   1044rpm
16:36:53.828    ->   CMD= 2000rpm(TEST)  DUTY= 53.5% TACH=   2029rpm
16:36:54.850    ->   CMD= 2000rpm(TEST)  DUTY= 53.8% TACH=   2004rpm
16:36:55.609    ->   CMD= 2000rpm(TEST)  DUTY= 53.7% TACH=   2004rpm
16:36:56.605    ->   CMD= 2000rpm(TEST)  DUTY= 53.6% TACH=   2007rpm
16:36:57.413    ->   SET RPM= 1000 (TEST) 1789mV     
16:36:57.633    ->   CMD= 1000rpm(TEST)  DUTY= 8.2%  TACH=   1972rpm
16:36:58.647    ->   CMD= 1000rpm(TEST)  DUTY= 20.6% TACH=   1055rpm
16:36:59.613    ->   CMD= 1000rpm(TEST)  DUTY= 22.3% TACH=   962rpm
16:37:00.648    ->   CMD= 1000rpm(TEST)  DUTY= 22.6% TACH=   976rpm
16:37:01.611    ->   CMD= 1000rpm(TEST)  DUTY= 22.8% TACH=   983rpm

2000→1000rpmの切り替え(16:36:57)では、Dutyがいったん下限付近(8.2%)に張り付きます。回転数は962rpmまで下がり、4秒後でも983rpmで、指示の1000rpmに戻りきっていません。

解析

step_response.py の処理は次のとおりです。

  1. TACH波形から、ヒステリシス付きのしきい値(振幅の35%と65%)でパルスの立ち上がり時刻を求め、隣り合う間隔から回転数を計算します。
  2. 同期信号の立ち上がり・立ち下がりを、指示を切り替えた時刻(t=0)とします。
  3. ステップごとに、次の指標を出します。
    • 遅れ:応答が10%に達するまでの時間
    • 遷移時間:10%から90%までの時間
    • τ63:63.2%に達するまでの時間(制御の遅れとファンの慣性を含みます。)
    • 整定時間:ステップ幅の±2%(または15rpm)の帯に入って出なくなるまでの時間
    • オーバーシュート:目標値を通り過ぎた量

step_response.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DS1054Z で取った TACH 波形 (CSV) から、回転数の時間変化とステップ応答の指標を求める。

使い方
  1) 1つのCSVに time, TACH, SYNC, [VD] の順で入っている場合
       python step_response.py capture.csv
  2) チャネルごとのCSV (UltraScope の Save Channel) の場合
       python step_response.py --tach ch1.csv --sync ch2.csv [--vd ch3.csv]
  時間列が無いCSV (電圧だけ) のときは --dt でサンプル間隔[s]を指定 (例: 100kSa/s なら --dt 1e-5)

出力 (CSVと同じフォルダ、または --out で指定したプレフィックス)
  *_rpm.csv  : 回転数の時系列 (t_rel[s], rpm)。t=0 は最初のSYNC立ち上がり
  *_plot.png : 回転数と SYNC (と VD) のグラフ
  コンソール : ステップごとの 遅れ / 立ち上がり時間 / τ63 / 整定時間 / オーバーシュート

前提: TACH は 1回転あたり --ppr パルス (既定2)。SYNC はスケッチの GPIO27 (High=高rpm指示)。
"""
import argparse
import os
import sys

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt


# ---------------------------------------------------------------- CSV 読み込み
def _is_number(s):
    try:
        float(s)
        return True
    except (TypeError, ValueError):
        return False


def _sniff_sep(first_line):
    best, cnt = ",", -1
    for sep in (",", "\t", ";"):
        c = first_line.count(sep)
        if c > cnt:
            best, cnt = sep, c
    return best


def load_csv(path, dt=None):
    """CSVを読み、(t, [列...]) を返す。時間列が無い場合は dt から作る。"""
    with open(path, "r", encoding="utf-8-sig", errors="replace") as f:
        head = [next(f, "") for _ in range(6)]
    head = [h.rstrip("\r\n") for h in head if h.strip() != ""]
    if not head:
        raise ValueError(f"{path}: 空のファイルです")
    sep = _sniff_sep(head[0])
    rows = [[c.strip() for c in h.split(sep)] for h in head]

    # ヘッダ行 (先頭セルが数値でない行) を数える
    n_header = 0
    for r in rows:
        if _is_number(r[0]) or (r[0] == "" and len(r) > 1 and _is_number(r[1]) and n_header > 0):
            break
        n_header += 1

    start = inc = None
    # Rigol 本体保存形式: ヘッダに Start / Increment があり、直下の行に値がある
    if n_header >= 1:
        names = [c.lower() for c in rows[0]]
        if "start" in names and "increment" in names and len(rows) > 1:
            try:
                start = float(rows[1][names.index("start")])
                inc = float(rows[1][names.index("increment")])
            except (ValueError, IndexError):
                start = inc = None
            n_header = 2

    df = pd.read_csv(path, header=None, sep=sep, skiprows=n_header, dtype=float,
                     na_values=[""], engine="c", encoding="utf-8-sig")
    df = df.dropna(axis=1, how="all")
    arr = df.to_numpy()
    n = arr.shape[0]

    if start is not None and inc is not None:                 # 本体形式: [index, volt]
        t = start + np.arange(n) * inc
        cols = [arr[:, i] for i in range(1, arr.shape[1])]
        return t, cols

    if arr.shape[1] >= 2:
        d = np.diff(arr[:, 0])
        looks_time = np.all(d > 0) or (np.mean(d > 0) > 0.999)
        is_index = looks_time and np.allclose(d, 1.0)
        if looks_time and not is_index:                        # 先頭列が時間
            return arr[:, 0], [arr[:, i] for i in range(1, arr.shape[1])]
        if is_index:
            if dt is None:
                raise ValueError(f"{path}: 時間列が無いので --dt でサンプル間隔[s]を指定してください")
            return arr[:, 0] * dt, [arr[:, i] for i in range(1, arr.shape[1])]
    # 1列 (電圧だけ)
    if dt is None:
        raise ValueError(f"{path}: 時間列が無いので --dt でサンプル間隔[s]を指定してください")
    return np.arange(n) * dt, [arr[:, 0]]


# ---------------------------------------------------------------- 波形処理
def schmitt_edges(t, v, lo_th, hi_th):
    """ヒステリシス付きのエッジ検出。立ち上がり(hi_th通過)と立ち下がり(lo_th通過)の時刻 [s] を返す。"""
    n = len(v)
    state = np.full(n, -1, dtype=np.int8)
    state[v > hi_th] = 1
    state[v < lo_th] = 0
    idx = np.where(state >= 0, np.arange(n), 0)
    np.maximum.accumulate(idx, out=idx)
    st = state[idx]
    st[st < 0] = 0
    rise = np.flatnonzero((st[1:] == 1) & (st[:-1] == 0)) + 1
    fall = np.flatnonzero((st[1:] == 0) & (st[:-1] == 1)) + 1

    def cross(i, level):
        v0, v1 = v[i - 1], v[i]
        if v1 == v0:
            return t[i]
        f = (level - v0) / (v1 - v0)
        f = min(max(f, 0.0), 1.0)
        return t[i - 1] + f * (t[i] - t[i - 1])

    tr = np.array([cross(i, hi_th) for i in rise])
    tf = np.array([cross(i, lo_th) for i in fall])
    return tr, tf


def levels(v):
    lo, hi = np.percentile(v, 2), np.percentile(v, 98)
    return lo, hi


def tach_to_rpm(t, v, ppr):
    lo, hi = levels(v)
    amp = hi - lo
    if amp < 0.5:
        print(f"警告: TACH の振幅が {amp:.2f}V と小さいです。チャネル設定 (V/div, カップリング) を確認してください。")
    tr, _ = schmitt_edges(t, v, lo + 0.35 * amp, lo + 0.65 * amp)
    if len(tr) < 5:
        raise ValueError("TACH のエッジが見つかりません (回転していない/しきい値が合わない)")
    dte = np.diff(tr)
    rpm = 60.0 / (ppr * dte)
    tm = 0.5 * (tr[1:] + tr[:-1])
    # 外れ値 (グリッチ由来の短い/長い間隔) を、周囲の中央値との比で除く
    med = pd.Series(rpm).rolling(7, center=True, min_periods=3).median().to_numpy()
    ok = np.abs(rpm - med) <= 0.25 * med
    return tm[ok], rpm[ok], int((~ok).sum())


def first_cross(tt, y, level, t_from):
    """t_from 以降で y が level 以上になる最初の時刻 (線形補間)。無ければ None。"""
    m = tt >= t_from
    tt, y = tt[m], y[m]
    hit = np.flatnonzero(y >= level)
    if len(hit) == 0:
        return None
    i = hit[0]
    if i == 0:
        return tt[0]
    y0, y1 = y[i - 1], y[i]
    f = 0.0 if y1 == y0 else (level - y0) / (y1 - y0)
    return tt[i - 1] + f * (tt[i] - tt[i - 1])


def analyze_step(tm, rpm, ts, tn, t_end, args):
    """ts で起きたステップの指標。tn は次のステップ時刻 (無ければ None)。"""
    seg_end = tn if tn is not None else t_end
    if seg_end - ts < args.min_seg:
        return None, f"区間が短い ({seg_end - ts:.1f}s < {args.min_seg:.1f}s)"
    pre = rpm[(tm >= ts - 1.0) & (tm <= ts - 0.1)]
    post = rpm[(tm >= seg_end - 1.0) & (tm <= seg_end - 0.1)]
    if len(pre) < 3 or len(post) < 3:
        return None, "前後の安定区間が足りません"
    before, after = float(np.median(pre)), float(np.median(post))
    delta = after - before
    if abs(delta) < args.min_step:
        return None, f"変化が小さい ({delta:+.0f}rpm)"
    m = (tm >= ts - 0.05) & (tm < seg_end)
    tt, rr = tm[m], rpm[m]
    y = (rr - before) / delta
    res = {"before": before, "after": after, "delta": delta}
    t10 = first_cross(tt, y, 0.10, ts)
    t63 = first_cross(tt, y, 0.632, ts)
    t90 = first_cross(tt, y, 0.90, ts)
    res["delay"] = None if t10 is None else t10 - ts
    res["rise"] = None if (t10 is None or t90 is None) else t90 - t10
    res["tau63"] = None if t63 is None else t63 - ts
    tol = max(args.band / 100.0 * abs(delta), 15.0)
    out = np.flatnonzero(np.abs(rr - after) > tol)
    if len(out) == 0:
        res["settle"] = 0.0
    elif out[-1] >= len(rr) - 2:
        res["settle"] = None                                    # 区間の最後まで帯に入らない
    else:
        res["settle"] = tt[out[-1] + 1] - ts
    peak = rr.max() if delta > 0 else rr.min()
    res["over"] = max(0.0, (peak - after) / delta * 100.0) if delta > 0 else max(0.0, (after - peak) / abs(delta) * 100.0)
    res["tol"] = tol
    return res, ""


def fmt(x, unit="s", nd=2):
    return "  --  " if x is None else f"{x:.{nd}f}{unit}"


# ---------------------------------------------------------------- main
def main():
    ap = argparse.ArgumentParser(description="DS1054Z のTACH波形(CSV)から回転数のステップ応答を解析する")
    ap.add_argument("capture", nargs="?", help="time, TACH, SYNC, [VD] を含む1つのCSV")
    ap.add_argument("--tach", help="TACH (CH1) のCSV")
    ap.add_argument("--sync", help="SYNC (CH2) のCSV")
    ap.add_argument("--vd", help="VD 平均 (D25) のCSV (任意)")
    ap.add_argument("--dt", type=float, help="サンプル間隔 [s] (時間列が無いCSVのとき)")
    ap.add_argument("--ppr", type=int, default=2, help="TACHの1回転あたりのパルス数 (既定 2)")
    ap.add_argument("--band", type=float, default=2.0, help="整定の許容帯 [%% of step] (既定 2、下限15rpm)")
    ap.add_argument("--min-step", type=float, default=150.0, help="解析するステップの最小変化 [rpm]")
    ap.add_argument("--min-seg", type=float, default=3.0, help="解析するステップの最小保持時間 [s] (既定 3)")
    ap.add_argument("--out", help="出力のプレフィックス (既定: 入力CSVと同じ場所)")
    ap.add_argument("--xlim", help="グラフの時間範囲 例: -2,10")
    args = ap.parse_args()

    if args.capture:
        t, cols = load_csv(args.capture, args.dt)
        if len(cols) < 2:
            sys.exit("エラー: time, TACH, SYNC の3列以上が必要です (チャネルごとのCSVなら --tach/--sync を使う)")
        tach, sync = cols[0], cols[1]
        vd = cols[2] if len(cols) >= 3 else None
        t_sync = t_vd = t
        base = args.capture
    elif args.tach and args.sync:
        t, cols = load_csv(args.tach, args.dt)
        tach = cols[0]
        t_sync, cols = load_csv(args.sync, args.dt)
        sync = cols[0]
        vd = t_vd = None
        if args.vd:
            t_vd, cols = load_csv(args.vd, args.dt)
            vd = cols[0]
        base = args.tach
    else:
        ap.error("CSV を1つ指定するか、--tach と --sync を指定してください")

    prefix = args.out or os.path.splitext(base)[0]

    # --- TACH -> rpm
    tm, rpm, dropped = tach_to_rpm(t, tach, args.ppr)
    print(f"TACH: {len(tm)} 点 (外れ値 {dropped} 点を除外)  回転数 {rpm.min():.0f}〜{rpm.max():.0f} rpm")

    # --- SYNC の立ち上がり/立ち下がり
    lo, hi = levels(sync)
    if hi - lo < 1.0:
        print(f"警告: SYNC の振幅が {hi - lo:.2f}V と小さいです。")
    mid, band = 0.5 * (lo + hi), 0.1 * (hi - lo)
    sr, sf = schmitt_edges(t_sync, sync, mid - band, mid + band)
    steps = sorted([(x, "up") for x in sr] + [(x, "down") for x in sf])
    if not steps:
        sys.exit("エラー: SYNC のエッジが見つかりません")
    t0 = sr[0] if len(sr) else steps[0][0]
    t_end = float(min(t[-1], t_sync[-1]))
    print(f"SYNC: 立ち上がり {len(sr)} 回, 立ち下がり {len(sf)} 回  (t=0 は最初の立ち上がり)\n")

    # --- ステップごとの指標
    print(f"{'ステップ':<8}{'時刻':>8}{'前→後 [rpm]':>16}{'遅れ(10%)':>11}{'立上り(10-90%)':>16}{'τ63':>9}{'整定(±%.0f%%)' % args.band:>14}{'オーバーシュート':>16}")
    for k, (ts, kind) in enumerate(steps):
        tn = steps[k + 1][0] if k + 1 < len(steps) else None
        res, why = analyze_step(tm, rpm, ts, tn, t_end, args)
        label = "低→高" if kind == "up" else "高→低"
        if res is None:
            print(f"{label:<8}{ts - t0:>7.2f}s  (解析なし: {why})")
            continue
        settle = "未収束" if res["settle"] is None else fmt(res["settle"])
        print(f"{label:<8}{ts - t0:>7.2f}s{res['before']:>8.0f}→{res['after']:<6.0f}"
              f"{fmt(res['delay']):>11}{fmt(res['rise']):>16}{fmt(res['tau63']):>9}{settle:>14}{res['over']:>14.1f}%")
    print("\n遅れ=ステップから応答が10%に達するまで / τ63=63.2%に達するまで (制御の遅れ+ファンの慣性を含む)")
    print(f"整定=±(ステップの{args.band:.0f}% または15rpm の大きい方) の帯に入って出なくなる時刻")

    # --- 出力
    pd.DataFrame({"t_rel_s": tm - t0, "rpm": rpm}).to_csv(prefix + "_rpm.csv", index=False)

    fig, axes = plt.subplots(2, 1, figsize=(10, 6.5), sharex=True, gridspec_kw={"height_ratios": [3, 1.3]})
    ax = axes[0]
    ax.plot(tm - t0, rpm, ".-", ms=2, lw=0.6, color="tab:blue", label="TACH rpm")
    for ts, kind in steps:
        ax.axvline(ts - t0, color="gray", lw=0.6, ls=":")
    ax.set_ylabel("rpm")
    ax.grid(alpha=0.3)
    ax.legend(loc="best")
    ax.set_title("Step response (from TACH edges)")
    ax2 = axes[1]
    ax2.plot(t_sync - t0, sync, lw=0.8, color="tab:red", label="SYNC (High = high rpm command)")
    if vd is not None:
        ax3 = ax2.twinx()
        ax3.plot(t_vd - t0, vd, lw=0.8, color="tab:green", label="VD (avg)")
        ax3.set_ylabel("VD [V]")
    ax2.set_ylabel("SYNC [V]")
    ax2.set_xlabel("time from first rising SYNC [s]")
    ax2.grid(alpha=0.3)
    ax2.legend(loc="lower right")
    if args.xlim:
        a, b = [float(x) for x in args.xlim.split(",")]
        ax.set_xlim(a, b)
    fig.tight_layout()
    fig.savefig(prefix + "_plot.png", dpi=150)
    print(f"\n保存: {prefix}_rpm.csv, {prefix}_plot.png")


if __name__ == "__main__":
    try:
        main()
    except ValueError as e:
        sys.exit(f"エラー: {e}")

結果

SCPI, 1000 → 2000 → 1000rpm 回転数グラフ

SCPI
SCPI, 1000 → 2000 → 1000rpm 回転数グラフ

定量分析

ステップ 時刻 前→後 [rpm] 遅れ(10%) 立上り(10-90%) τ63 整定(±2%) オーバーシュート
低→高 0.00s 980→1995 0.03s 0.37s 0.23s 0.96s 3.7%
高→低 5.00s 1995→980 0.07s 0.78s 0.50s 3.01s 4.7%
  • 上りは速い。 10%まで0.03秒、63%まで0.23秒で、約1秒で整定します。
  • 下りは遅い。 τ63が0.50秒、整定が3.0秒。ファンはPWMでブレーキを掛けられず、空気抵抗と摩擦で自然に減速します。指示を下げた直後はDutyが下限付近に張り付く(ログ)。行き過ぎたあとは、PIの積分が戻るのに時間がかかる(積分の時定数は、設計値で約2秒)。ここは推定です。
  • 下げたあとの定常値は980rpmで、指示より約2%低いです。

5. F/V変換で、オシロから直接見る

回路図とシミュレーション

回転数を電圧で直接見るために、555のモノステーブル(ワンショット)とLPFでF/V変換を作りました。TACHの各パルスで固定幅Twのパルスを出し、それを平均すると、電圧が周波数に比例します。

Vout ≈ Vh × Tw × f_tach     (f_tach = rpm ÷ 30)
Tw = 1.1 × R1 × C1 ≈ 6.6ms  (R1=60kΩ、C1=0.1µF)
LPF:38kΩ × 10µF(τ=0.38秒、fc≈0.4Hz)
  • 2500rpm(83Hz)でも、デューティは約57%で、パルスは重なりません。
  • NE555は電源電圧が4.5V以上なので、3.3Vで使うならCMOS版(TLC555など)を使います。
  • 1000rpm・2000rpmは33Hz・67Hzに当たります。
  • 回転数N[rpm]とLPF出力Voutの関係は、 $$V_{out}=3.3 \times \frac{2\cdot N}{60} \times 6.6m =\frac{N}{1377}$$ $$N[rpm] = 1377 \times V_{out} [V]$$

シミュレーションは$1000rpm \Leftrightarrow 2000rpm$ のステップ応答を見たものです。fc=0.4Hz なので大きな遅れが見られます。

また、モータを含んでいないF/V変換のみです。次項のオシロ波形はF/V変換を通したモータ回転数のステップ応答です。

回路図
回路図とシミュレーション

結果

F/V変換
F/V変換

  • 立ち上がり:
    F/V出力は約1秒で立ち上がります。LPFだけの10〜90%は 2.2τ ≈ 0.8秒で、ほぼ同じです。見えている立ち上がりは主にLPFの応答で、実際の回転の立ち上がり(10〜90%で0.37秒、第4項)はこれより速く、F/Vでは分解できません。
  • 立ち下りの落ち込み:
    F/V出力は、元のレベルより約0.14V(ステップ高さの約24%)下まで落ち込み、2〜3秒で戻ります。一方、TACH解析のアンダーシュートは4.7%でした。LPFは落ち込みを小さくする側にしか働かないので、この差はLPFでは説明できません。
  • オーバシュートやアンダーシュートは発生するときもあるし、しないときもあります。原因は分かりません。

6. RAWデータとF/V変換の比較

停止状態からは1000rpm以上の回転でなければ回り始めないので、起動キック(1秒間1300rpm程度の高回転)の処理をしています。その状態をTACHのRAWデータとF/V変換で比較してみます。

F/V変換では遅れがあることと、そのためピークが丸められることが良くわかります。

比較
$0rpm \Leftrightarrow 2000rpm$ のステップ応答比較

7. まとめ

  • UltraScopeのCSV(約1,200点)では、2ms刻みの量子化で、パルス間隔が14msか16msに丸められます。周期解析には向きません。
  • SCPI+PythonのRAWモードなら、2ch・600k点で20µs刻みが得られ、量子化の影響は ±0.13% まで小さくなります。ステップ応答は、上りが速く(τ63=0.23秒)、下りが遅い(τ63=0.50秒、整定3.0秒、アンダーシュート4.7%)。
  • F/V変換は、傾向をオシロで見るのに便利です。ただしLPFの遅れが入り、細部の定量化には使えません。

参考

3線式DCファンをESP32でPWM駆動する実験

要点

  • 定格2500rpmの3線式ファンの電源ラインを25kHzでPWM制御したところ、Duty 5%で2000rpm(ファン電圧約10V)、10%で2500rpmのフル回転になりました。これではDutyで速度が定まりません。
  • 推定される原因は2つ考えられます。ゲートOFFが遅れて実効ONが指令の約5倍に延びること、あるいはファン入力が容量性であり短いONでも満充電されることです。
  • 対策にファンの手前に8mHのチョークを直列に入れてバック(降圧)化すると、Duty 10〜70%で560〜2470rpmと、ほぼリニアに変わるようになりました。

1. 目的と回路

アナログ制御の実験では、NchローサイドだとファンのGNDが浮いてしまい、TACHをマイコンで読めませんでした。Pchハイサイド(TPC8408)にしたところ、330rpmでも0/3.3VのTACH信号が得られました。

今回はこの回路をESP32のPWM(25kHz)で駆動します。

  • PWM:GPIO26 → Rb → Q1(2SC1815)→ Pch MOSFETのゲート。PWM HighでMOSFET ON(Duty=ON比率)
  • D1:転流ダイオード 
  • R1(2Ω):電流シャント 
  • TACH:GPIO32

回路図

回路図
【図1:回路図】

Duty 5% シミュレーション

sim
【図2:Duty 5% シミュレーション】【図3:右波形:Duty Vs モータ電圧 V(シアン)、回転数 rpm(ピンク)】

2. Step1:25kHzで回すと“Duty 10%でフル回転”

シミュレーションでは Duty 0.05 で ドレイン電圧V(D) 2.75V, 300rpm でした。ところが実機では Duty 5% で FAN_V≈10V、約2000rpmになり、10%でフル回転(2400〜2500rpm)でした。シャント電流には約1.1Aのピークも現れました。

16:25:19.599 -> DUTY= 5.0%  FAN_V= 9.71V  VD= 9.85V  TACH= 2084rpm  LED= 2084rpm  SIM= 1922rpm  IPK= 0.091A  IMAX= 0.180A  IAVG= 0.072A  TS= 999876us
16:25:21.030 -> DUTY= 5.0%  FAN_V= 9.46V  VD= 9.73V  TACH= 2082rpm  LED= 2082rpm  SIM= 1864rpm  IPK= 0.569A  IMAX= 0.771A  IAVG= 0.133A  TS= 999876us
16:25:22.532 -> DUTY= 5.0%  FAN_V= 9.64V  VD= 10.05V  TACH= 2083rpm  LED= 2082rpm  SIM= 1906rpm  IPK= 0.961A  IMAX= 1.072A  IAVG= 0.205A  TS= 999877us

Duty 10% 2400rpm

Duty 10%
【図4: Duty 10% 2400rpm】

実機で低回転にならない現象の原因究明のため、GPIO26(PWM)・Gノード・Dノードの波形を観察し、現象と原因推定を行いました。

問題: Duty 5%で約2000rpmになる

要因 現象(波形) 原因 対策
A 指令(2ch)2µsに対し、MOSFET(3ch) が約10µs ON ゲートを元の電圧に戻す側が、Rpu=1kΩ の受動プルアップだけ
ゲート電荷(データシート Qg 約24nC)をその抵抗で充電するので遅くなる
ゲートOFFを速くするために、Rpu を小さくする(例: 470Ω、Q1の電流は約25mA)か、プッシュプルで引き上げる
B D(4ch) が約11.7V↔8.7V で、平均約10V(ほぼ12V近く) ファン入力がコンデンサ状(推定)で、MOSFET がそれを直接充電している
ONが約10µsもあれば満充電され、Duty が平均電圧にならない
MOSFET とファンの間にインダクタ(L)をいれる
充電がゆっくりになり、ファン電圧が 12V × Duty に近づく

A の根拠: ゲート電荷 24nC を 10V 振幅で割ると実効容量は約2.4nF で、時定数は約2.6µs(概算)です。S−|Vth|(Vth −0.8〜−2.0V)まで戻すには数τとミラー期間が必要で、実測の約10µs と整合します。

B の位置づけ: ファン内部のコンデンサは、波形からの推定です。内部は測っていません。Dの垂れ下がり(約3V、約40µs、電流約0.1A)から C≈1〜2µF です。ピーク電流は (12V−8.7V)/2Ω≈1.5A であり、実測の1.1Aと同程度です。

対策の優先: 前項のシミュレーション波形(下段 V(d))と比較すると、要因Bが高止まりの主因と判断できます。回転数を広く設定する本命は B の対策インダクタ(L)です。

A の対策だけでは、B の現象が残ります。A の対策は、B の対策に加えて Duty の低い領域の再現性を上げるためのものです。

Duty 5%のG/D波形

【図5:Duty 5%のG/D波形】
【図5:Duty 5%のG/D波形】

モータモデル変更(R: 100Ω, C: 1uF)

変更点: オシロ波形からFANモータには容量Cが含まれていることが分かったので、R5+L1 を、C(約1〜2µF)とR_load(V/I ≈ 100Ω前後)の並列に換えます。C は D の垂れ下がりから、R_load は VD と IAVG から仮決めして、シミュレーションで部調整しました。

Duty 5%で VD≈10V、鋸歯状の Dain電圧、電流ピーク約1.7A (実機は約1A)、2000rpm と実機相当の特性が再現できました。推定した原因の妥当性が検証されたと言えます。

+C
【図6:容量C追加のシミュレーション】

3. 対策:8mHインダクタでバック(降圧コンバータ)化

MOSFETとファンの間にインダクタを直列に入れると、MOSFET + D1 + L + ファン内蔵C が降圧コンバータになります。連続モードであれば Vout ≈ D × Vin です。(DはDuty $D$)

インダクタLの大きさは「電流が途切れないこと」と「リップル」で決まります。25kHz・負荷電流0.07〜0.1Aでは、一般的なDC-DC用の数十µHでは足りません。

L リップル電流(D=0.5) 判定
100µH 1.2 A p-p 負荷電流を超え、電流が途切れる
2.2mH 55 mA p-p 十分
8mH 15 mA p-p 十分(実験で使用)

連続モードの下限:$L \geq \cfrac{Vin} {8·f·Iout} = \cfrac{12}{8 \cdot 25k \cdot 0.07} \approx 0.86mH$(12V、25kHz、0.07A)です。

手持ちには1mH程度のインダクターが無いので、ラインノイズフィルタ(コモンモードチョークコイル)ELF21NA027を使用しました。インダクタンスは実測で8mHです。

その結果、Dが0↔12Vのスイッチング波形になり、Duty→回転数が素直になりました。

8mH追加後のG/D波形(Duty 5%)

D 5%
【図7:8mH追加 Duty 5%のG/D波形】
8mH追加後のG/D波形(Duty 30%)
30%
【図8:8mH追加後のG/D波形(Duty 30%)】

実験結果:Dutyと回転数の関係をまとめると次表になります。

Duty ファン電圧 回転数
7% – 290 rpm(下限;停止直前)
10% 約5.0V 560 rpm
30% 約7.0V 1244 rpm
50% 約9.2V 1890 rpm
70% 約11.4V 2470 rpm
80%以上 約11.7V 約2530 rpm(飽和)

コラム1:4線式ファンはなぜ大きなLが不要なのか

一般的な4線式ファンは、内部のドライバICに常時給電し、PWM信号ではモータ巻線だけをスイッチします。巻線自体がLなので、外付けのLC平滑は不要です。一方、3線式ではモータ巻線以外に内部のICとコンデンサに同時に給電しています。PWM駆動すると電源ごと切れ、内部のICとコンデンサを巻き込みます。

3線式(外部PWM) vs 4線式(専用PWM)の比較

比較項目 3線式ファンの外部PWM駆動 4線式PWMファン(専用品)
スイッチング対象 電源ライン全体(12V) モータ巻線(L)のみ
内部IC / センサ電源 0V ↔ 12V で断続(リセット・不安定化) 常時12V DC(連続給電で安定動作)
内部コンデンサ(\(C_{in}\)) パルス毎に直列充電 \(\rightarrow\) 突入電流・電圧高止まり 12V固定のため充放電パルスゼロ
使用するインダクタ(\(L\)) 内部Lが効かず、外付け大型L(1mH~)が必須 モータ巻線自体の\(L\)をそのまま利用
技術的本質 外付けLC回路による電源降圧(DC-DC化) 「IC給電」と「巻線スイッチ」の分離

技術的な違いの要点

  • 3線式: 電源ラインごとパルス切断するため、内部コンデンサの悪影響を消すための「外付け降圧回路(大型L)」が必要になる。
  • 4線式: 制御線を1本増やして「ICへの常時12V給電」と「モータ巻線のスイッチング」を分離したため、モータ自体のコイル(L)だけで動作が完結する。

コラム2:FETの選び方は、アナログとPWMで逆になる

  • サーミスタ連動のアナログ制御:小さなgm(2N7000など)で変化を鈍くし、回転数をなめらかにします。
  • PWM:低Rds(on)・小Qg・高gm(TPC8408など)で、ON/OFFをきっぱり切り替えます。今回のゲート遅れも、Qgとプルアップ抵抗の時定数によるものでした。

アナログ定電圧制御 vs PWM駆動におけるFET選定の対比

制御方式 FETに求める役割 重要なFET特性 設計上の意図
アナログ定電圧制御
(サーミスタ連動)
可変抵抗(リニア動作)
能動領域を広く使う
小さな相互コンダクタンス(\(g_m\))
(例: 2N7000など)
ゲート電圧の変化に対するドレイン電流の変化をあえて鈍くし、温度変化による回転数の急変(ON/OFF的な挙動)を防いで滑らかに制御する。
PWM駆動
(25kHz スイッチング)
純粋な電子スイッチ
(遮断 ↔ 飽和の2状態)
低 \(R_{DS(on)}\)
小 \(Q_g\)(ゲート電荷量)
高 \(g_m\)
(例: AO3400A, TPC8408など)
中間状態(能動領域)は単なる「電力損失と発熱」でしかないため、高い\(g_m\)と小\(Q_g\)でON/OFFを瞬時に切り替え、低いオン抵抗で発熱を抑える。

技術的意味

  • 「アナログ制御では長所(メリット)だった小さな \(g_m\) が、PWM駆動ではスイッチング遅延や発熱という短所(デメリット)に変わる」
  • 「制御方式を変えると、FETに求める理想的なパラメータが180度真逆になる」

このことは今回実験してみて見えてきた興味深いポイントです。

4. Step2:POTとキック

以降は付加機能です。

POTの抵抗を変えると、Dutyが変更できるようにしてみました。

  • POT回路:3.3V — 3.6kΩ — POT(10kΩ)— 680Ω — GND と接続し、ワイパーをGPIO39へ、ワイパーとGND間に0.1µFを入れます。ワイパー電圧は0.157〜2.47Vとなり、ESP32のADC(11dB)の推奨範囲(150〜2450mV)に収まります。

  • 起動キック:停止中に低Dutyを指示されたら、Duty 30%を最大1秒間出してから設定値へ下げます。始動のヒステリシスを避けるためです。(下記のグラフはヒステリシスの存在を表すため、”起動キック”を入れていないときの特性です。)

Duty–回転数のグラフ

Dutyとワイパー抵抗、ワイパー電圧の関係(設計値)はグラフの横軸で表現しています。

DT
【図9:Duty–回転数のグラフ】

  • Duty 5%(約4.4V)では停止します。Duty 0%でも約4Vが乗るのは、ゲート遅れ(約10µs)の分です。
  • 始動にヒステリシスがあります。上げ側は10%で始動せず20~25%で始動し、下げ側は7%まで回り続けます。コギングや静止摩擦によるものと考えています。
  • 停止状態からの起動時には、一瞬(1秒間)強制的に Duty 30% を出力してコギングトルクを破り、その後設定値へ下げる「起動キック」ロジックを実装すると、ヒステリシスをなくすことができます。

5. Step3:回転数を指示する

指示値をDutyから回転数に変え、TACH基準でフィードバック制御を行ってみました。

  • 制御(100msごと):Duty = FF(実測のrpm→Duty表)+ PI(Gp=0.5、Gi=0.5)です。Duty→回転数の傾きが6〜90 rpm/%と大きく変わるため、誤差を表の傾きで正規化しています。
  • TACH:4kHzでエッジ時刻を取り、直近0.25秒のエッジから100msごとに回転数を算出します(中央値3で外れ値を除きます)。
  • 指示:POTで400〜2500rpm(下端付近で停止)、シリアル通信で0〜2500rpmです。
  • 構成:core1がサンプリングと制御を行い、core0がシリアル・OLED(U8g2)・LED解析を行います。

【図5:OLED表示】

OLED
【図10:OLED表示】

6. まとめ

  • 3線式ファンを電源ごとPWM制御すると、内部のCによってDutyが効かなくなります。Lを追加してバック化すると、素直に制御できるようになりました。
  • ゲート遅れ、Qg、プルアップ抵抗、ファンのCの関係は、波形を見なければ分かりませんでした。
  • 今後は、実機でのステップ応答を確認する予定です。

スケッチ

指示値の入力:

  • POT: 400〜2500rpm です。
  • シリアル: 0〜2500 です。1〜289 は 290rpm で動作し、「要求」付きで表示します。

fan_pwm4.ino

// ESP32: Pch高側PWM(25kHz)でFANを駆動。指示値は回転数[rpm] (TACH基準でフィードバック)。 (Step3)
// 駆動回路は MOSFET -> D1(転流) -> 8mHチョーク(直列) -> ファン (Step1の実測で確認済み)。
//
// ■ 指示値 (400〜2500rpm、停止は0)
//   ・POT : 回し切り(下端)で停止(0)。そこから戻すと 400〜2500rpm。
//   ・シリアル(115200bps, 改行あり): 0〜2500 の数値 + Enter (例: 1200)。0で停止。
//           1〜289 は Duty下限(7%)の都合で 290rpm として動作します (表示は "要求" 付き)。
//   ・最後に操作した方が有効。POTは指示値が POT_HYST_RPM 以上動いたときだけ操作とみなします。
//
// ■ 制御 (100msごと): Duty = FF(指示値) + P + I
//   FF : 実測の rpm→Duty 表 (下げ側の定常値, 8mHチョーク) を線形補間
//   P/I: TACHの誤差を、表の傾きで正規化して補正。Gp, Gi は「誤差のうち補正する割合」と「その積分 [1/s]」
//   起動キック: 停止中に FFが KICK_DUTY 未満の指示が来たら、KICK_DUTY を出し、rpmが指示値の80%に達したら通常制御へ
//   TACHが取れない間は FF のみ (状態 OPEN)
//
// ■ 構成
//   core1 loop()  : 4kHzで連続サンプリング(タコのエッジ検出, POT, 電流, VD, LED) + 100ms制御。ここでは重い処理をしない
//   core0 ui      : シリアル入出力, POT→指示値, 1秒ごとの状態出力
//   core0 oled    : OLED表示 (4Hz, U8g2)
//   core0 led     : LEDによる回転数のFFT風解析 (参考表示。制御には使いません)
//
// ■ ハード
//   GPIO26 PWM -> Rb -> Q1(2SC1815) -> Pch MOSFET。PWM High = MOSFET ON (Duty = ON比率)
//   GPIO39 POTワイパー: 3.3V - 3.6kΩ - POT(10kΩ) - 680Ω - GND、ワイパーとGND間に0.1µF
//   GPIO36 VD分圧, GPIO32 TACH, GPIO34 LED, GPIO35 シャント(R1=2Ω)
//   OLED(I2C): SDA=GPIO21, SCL=GPIO22。ライブラリ U8g2 が必要 (SH1106なら下の宣言を差し替え)
#include <Arduino.h>
#include <math.h>
#include <Wire.h>
#include <U8g2lib.h>

// ---- ピン ----
const int   PIN_PWM  = 26;
const int   PIN_POT  = 39;
const int   PIN_VFAN = 36;
const int   PIN_TACH = 32;
const int   PIN_LED  = 34;
const int   PIN_CUR  = 35;

// ---- OLED (SSD1306 128x64, I2C)。SH1106の場合は U8G2_SH1106_128X64_NONAME_F_HW_I2C に替える ----
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

// ---- PWM ----
const int      PWM_FREQ = 25000;        // [Hz]
const int      PWM_BITS = 10;           // Duty分解能 約0.1%
const int      PWM_CH   = 0;            // core 2.x のみ使用
const uint32_t PWM_FULL = 1u << PWM_BITS;

// ---- 指示値 ----
const float RPM_MAX      = 2500.0f;     // 指示値の上限
const float RPM_POT_MIN  = 400.0f;      // POT下限 (停止ゾーンの外)
const float RPM_RUN_MIN  = 290.0f;      // 動作できる最低回転 (実測: Duty 7%で290rpm、6%で停止)
const float RPM_STEP     = 10.0f;       // POT指示値の刻み
const float POT_HYST_RPM = 15.0f;       // POT指示値がこの値以上動いたら更新 (ノイズ対策のヒステリシス)
const int   POT_MV_LO    = 150;         // POT下端のADC電圧 [mV] (ESP32 11dBの推奨範囲 150〜2450mV)
const int   POT_MV_HI    = 2450;        // POT上端のADC電圧 [mV]
const float POT_STOP_ENTER = 0.03f;     // POT位置(0〜1)がこれ以下で停止
const float POT_STOP_EXIT  = 0.06f;     // これ以上で停止解除 (ヒステリシス)
const float POT_ALPHA    = 1.0f / 128.0f;   // POTの1次フィルタ (1kHz更新 -> 時定数約128ms)
const int   POT_INIT_N   = 128;         // 起動時の平均回数

// ---- 制御 ----
const uint32_t CTRL_MS      = 100;      // 制御周期
const float Gp              = 0.5f;     // P: 誤差のうち1回で補正する割合
const float Gi              = 0.5f;     // I: 誤差に対する積分ゲイン [1/s]
const float I_MAX           = 10.0f;    // 積分項の上下限 [Duty %]
const float I_BAND          = 400.0f;   // 誤差がこれ未満のときだけ積分 (停止中は例外)
const float CMD_RESET_RPM   = 200.0f;   // 指示値がこれ以上変わったら積分をリセット
const float DUTY_RUN_MIN    = 7.0f;     // 制御Dutyの下限 [%]
const float DUTY_CTL_MAX    = 90.0f;    // 制御Dutyの上限 [%]
const float RPM_STOPPED     = 100.0f;   // これ未満なら停止(惰性で回っている程度)とみなす
const float KICK_DUTY       = 30.0f;    // 起動キックのDuty [%]
const uint32_t KICK_MS      = 1000;     // 起動キックの最長時間
const float KICK_END_FRAC   = 0.8f;     // rpmが指示値のこの割合に達したらキック終了
const uint32_t STALL_MS     = 2000;     // 指示があるのに停止がこの時間続いたら再キック
const uint32_t KICK_RETRY_MS = 5000;    // 再キックの最短間隔
const float STALL_BOOST     = 1.5f;     // 固着で再キックするたびに積分項へ足す [Duty %] (下限付近の余裕を学習)

// 実測の rpm -> Duty 表 (8mHチョーク追加後、下げ側の定常値。7%=290rpmはご報告値)
const int   FF_N = 11;
const float FF_RPM[FF_N]  = { 290,  560,  740,  910, 1078, 1244, 1566, 1892, 2198, 2471, 2528 };
const float FF_DUTY[FF_N] = {   7,   10,   15,   20,   25,   30,   40,   50,   60,   70,   80 };

// ---- 測定 ----
const float VDIV       = 6.52f;         // 11.82V / 1.812V
const float VCAL_GAIN  = 0.9634f;       // DMM実測2点から算出
const float VCAL_OFFSET = -0.001f;      // [V]
const float SHUNT_OHM  = 2.0f;
const float TOP_FRAC   = 0.02f;         // IPK: 生サンプルの上位2%点
const int   TACH_PPR   = 2;
const int   BLADES     = 9;             // ファンの羽根枚数
const float SIM_K      = 234.2f;        // 回路図の rot = SIM_K * V - SIM_OFS (参考表示)
const float SIM_OFS    = 351.8f;

// タコ検出 (ADC + ヒステリシス + ブランキング)。しきい値は0.5秒ごとにヒストグラムから更新
const int   FS         = 4000;          // サンプリング周波数 [Hz]
const uint32_t SLOT_US = 1000000UL / FS;
const int   TH_WIN     = FS / 2;        // しきい値の更新周期 [sample]
const float TACH_TH_LOW  = 0.35f;
const float TACH_TH_HIGH = 0.65f;
const int   TACH_MIN_AMP = 300;
const float BLANK_FRAC   = 0.5f;        // 直近の周期の何割以内のエッジを無視するか
const float MIN_EDGE_GAP = 8.0f;        // 周期が不明なときの最小間隔 [sample]
const float STOP_GAP_S   = 1.2f;        // この時間エッジが無ければ停止
const float TACH_WIN_S   = 0.25f;       // 回転数の平均区間 (最低でも1周期分)
const int   EDGE_N       = 16;          // エッジ履歴 (2の累乗)

// 1秒窓 (電流の統計・LED解析)
const int   N          = FS;            // 1.000秒
const int   MAINS      = 60;            // 商用周波数 [Hz]
const int   HARM       = 12;
const int   FMIN       = 40;
const int   FMAX       = 800;
const float QUALITY_MIN = 6.0f;
const int   HIST_MAX   = 2500;          // 電流ヒストグラム上限 [mV]

// ---- 共有データ (サンプラ core1 <-> UI core0) ----
enum { ST_STOP = 0, ST_KICK = 1, ST_RUN = 2, ST_OPEN = 3 };
static const char *ST_NAME[] = { "STOP", "KICK", "RUN", "OPEN" };

static volatile float g_rpmCmd   = 0.0f;   // 有効な指示値 [rpm] (0=停止)
static volatile float g_tachRpm  = 0.0f;   // TACHの回転数 [rpm] (フィルタ後)
static volatile bool  g_thValid  = false;  // タコ信号を一度でも確認できた
static volatile float g_duty     = 0.0f;   // 出力Duty [%]
static volatile int   g_state    = ST_STOP;
static volatile float g_potMv    = 0.0f;   // POT電圧 [mV] (フィルタ後)
static volatile bool  g_potReady = false;
static volatile float g_fanv     = 0.0f;   // 表示用
static volatile float g_iavg     = 0.0f;
static volatile float g_ledRpm   = -1.0f;
static volatile int   g_ledReady = -1;
static const char *volatile g_cmdSrc = "---";

struct WinStat {
  volatile uint32_t seq;
  volatile float ipk, imax, iavg, vd;
  volatile uint32_t late;
};
static WinStat g_win;

static uint16_t ledBuf[2][N];             // LED ADC生値 (ダブルバッファ)
static float    x[N];                     // LED解析用 (coreのledタスクのみ使用)
static float    sinTab[FS];
static float    mag[FMAX - FMIN + 1];
inline float cosT(uint32_t i) { return sinTab[(i + FS / 4) % FS]; }

// ================== PWM ==================
static void pwmWrite(uint32_t v) {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
  ledcWrite(PIN_PWM, v);
#else
  ledcWrite(PWM_CH, v);
#endif
}

static void setDuty(float pct) {
  if (pct < 0.0f) pct = 0.0f;
  if (pct > 100.0f) pct = 100.0f;
  g_duty = pct;
  uint32_t v = (uint32_t)(pct / 100.0f * PWM_FULL + 0.5f);
  if (v > PWM_FULL) v = PWM_FULL;
  pwmWrite(v);
}

// ================== FF表 ==================
static float ffDuty(float rpm) {
  if (rpm <= FF_RPM[0]) return FF_DUTY[0];
  for (int i = 0; i < FF_N - 1; i++) {
    if (rpm <= FF_RPM[i + 1]) {
      float t = (rpm - FF_RPM[i]) / (FF_RPM[i + 1] - FF_RPM[i]);
      return FF_DUTY[i] + t * (FF_DUTY[i + 1] - FF_DUTY[i]);
    }
  }
  return FF_DUTY[FF_N - 1];
}

// 表の傾き dDuty/drpm [%/rpm] (飽和域の最終区間は除く)
static float ffSlope(float rpm) {
  int i = 0;
  while (i < FF_N - 2 && rpm > FF_RPM[i + 1]) i++;
  if (i > FF_N - 2) i = FF_N - 2;
  return (FF_DUTY[i + 1] - FF_DUTY[i]) / (FF_RPM[i + 1] - FF_RPM[i]);
}

// ================== サンプラ (core1) ==================
// --- タコ ---
static uint16_t tachHist[64];
static int      thCnt = 0;
static float    thL = 0, thH = 0;
static bool     thValid = false;
static bool     tState = false;
static uint16_t tPrev = 0;
static double   edgeT[EDGE_N];
static int      edgeHead = 0, edgeCnt = 0;
static float    lastInt = 0;               // 平滑化した周期 [sample]
static uint32_t sIdx = 0;                  // 通しサンプル番号

static void updateThresholds() {
  uint32_t need = TH_WIN / 100, acc = 0;
  int lo = 0, hi = 4095;
  for (int b = 0; b < 64; b++)  { acc += tachHist[b]; if (acc >= need) { lo = b * 64 + 32; break; } }
  acc = 0;
  for (int b = 63; b >= 0; b--) { acc += tachHist[b]; if (acc >= need) { hi = b * 64 + 32; break; } }
  if (hi - lo >= TACH_MIN_AMP) {
    thL = lo + TACH_TH_LOW  * (hi - lo);
    thH = lo + TACH_TH_HIGH * (hi - lo);
    if (!thValid) { thValid = true; tState = (tPrev > thH); g_thValid = true; }
  }
  memset(tachHist, 0, sizeof(tachHist));
}

static void acceptEdge(double t) {
  if (edgeCnt > 0) {
    double last = edgeT[(edgeHead - 1) & (EDGE_N - 1)];
    double gap = t - last;
    if (gap > STOP_GAP_S * FS) {            // 長い空白の後は履歴を捨ててやり直す
      edgeCnt = 0;
      lastInt = 0;
    } else {
      float minGap = (lastInt > 0) ? BLANK_FRAC * lastInt : MIN_EDGE_GAP;
      if (gap < minGap) return;
      lastInt = (lastInt > 0) ? 0.5f * lastInt + 0.5f * (float)gap : (float)gap;
    }
  }
  edgeT[edgeHead] = t;
  edgeHead = (edgeHead + 1) & (EDGE_N - 1);
  if (edgeCnt < EDGE_N) edgeCnt++;
}

static void tachSample(uint16_t v) {
  tachHist[v >> 6]++;
  if (++thCnt >= TH_WIN) { thCnt = 0; updateThresholds(); }
  if (thValid) {
    if (!tState) {
      if (v > thH) {
        tState = true;
        float frac = (v > tPrev) ? (thH - tPrev) / (float)(v - tPrev) : 0.0f;   // しきい値の交差点を線形補間
        if (frac < 0.0f) frac = 0.0f;
        if (frac > 1.0f) frac = 1.0f;
        acceptEdge((double)(sIdx - 1) + frac);
      }
    } else if (v < thL) {
      tState = false;
    }
  }
  tPrev = v;
}

// エッジ履歴から回転数 [rpm]。直近 TACH_WIN_S 以内のエッジを使う (最低1周期)
static float tachRaw() {
  if (edgeCnt < 2) return 0.0f;
  double tl = edgeT[(edgeHead - 1) & (EDGE_N - 1)];
  double age = (double)sIdx - tl;
  if (age > STOP_GAP_S * FS) return 0.0f;
  int nI = edgeCnt - 1;
  int m = 1;
  while (m < nI) {
    double span = tl - edgeT[(edgeHead - 2 - m) & (EDGE_N - 1)];
    if (span > TACH_WIN_S * FS) break;
    m++;
  }
  double span = tl - edgeT[(edgeHead - 1 - m) & (EDGE_N - 1)];
  float per = (float)(span / m);
  float rpm = FS * 60.0f / (TACH_PPR * per);
  if (age > 2.2 * per) {                    // エッジが来ない: 減速/停止中なので上限を下げる
    float bound = FS * 60.0f / (TACH_PPR * (float)age);
    if (bound < rpm) rpm = bound;
  }
  return rpm;
}

static float med3(float a, float b, float c) {
  if (a > b) { float t = a; a = b; b = t; }
  if (b > c) b = c;
  return (a > b) ? a : b;
}

static float tachUpdate() {
  static float r1 = 0, r2 = 0;
  float raw = tachRaw();
  float m = med3(raw, r1, r2);              // 1回きりの外れ値を除く
  r2 = r1;
  r1 = raw;
  g_tachRpm = m;
  return m;
}

// --- 制御 ---
static int      st = ST_STOP;
static float    integ = 0.0f;
static float    cmdPrev = 0.0f;
static uint32_t kickEnd = 0, lastKick = 0, stallStart = 0;

static void controlStep() {
  const float dt = CTRL_MS / 1000.0f;
  float rpm = tachUpdate();
  bool tachOk = thValid;
  float cmd = g_rpmCmd;
  uint32_t now = millis();
  int prevSt = st;
  float duty = 0.0f;

  if (cmd <= 0.0f) {
    st = ST_STOP;
    integ = 0.0f;
    stallStart = 0;
    duty = 0.0f;
  } else {
    float ff = ffDuty(cmd);
    bool changed = (cmd != cmdPrev);
    if (st == ST_STOP || changed) {
      if (fabsf(cmd - cmdPrev) > CMD_RESET_RPM) integ = 0.0f;
      bool stopped = tachOk ? (rpm < RPM_STOPPED) : (cmdPrev <= 0.0f);
      if (stopped && ff < KICK_DUTY) {
        st = ST_KICK;
        kickEnd = now + KICK_MS;
      } else if (st == ST_STOP || st == ST_KICK) {
        st = ST_RUN;
      }
    }
    if (st == ST_KICK) {
      duty = KICK_DUTY;
      if ((int32_t)(now - kickEnd) >= 0 || (tachOk && rpm >= KICK_END_FRAC * cmd)) {
        st = ST_RUN;
      }
    }
    if (st != ST_KICK) {
      if (!tachOk) {
        st = ST_OPEN;                       // TACH無し: FFのみ
        duty = ff;
      } else {
        st = ST_RUN;
        if (prevSt == ST_OPEN) integ = 0.0f;
        float e = cmd - rpm;
        float S = ffSlope(cmd);
        if (fabsf(e) < I_BAND || rpm < 50.0f) {
          integ += Gi * S * e * dt;
          if (integ >  I_MAX) integ =  I_MAX;
          if (integ < -I_MAX) integ = -I_MAX;
        }
        duty = ff + Gp * S * e + integ;
        // 指示があるのに止まっている (固着) -> 再キック
        if (rpm < RPM_STOPPED) {
          if (stallStart == 0) stallStart = now;
          else if ((now - stallStart) > STALL_MS && (now - lastKick) > KICK_RETRY_MS && ff < KICK_DUTY) {
            st = ST_KICK;
            kickEnd = now + KICK_MS;
            lastKick = now;
            stallStart = 0;
            integ += STALL_BOOST;
            if (integ > I_MAX) integ = I_MAX;
            duty = KICK_DUTY;
          }
        } else {
          stallStart = 0;
        }
      }
      if (st != ST_KICK) {
        if (duty < DUTY_RUN_MIN) duty = DUTY_RUN_MIN;
        if (duty > DUTY_CTL_MAX) duty = DUTY_CTL_MAX;
      }
    }
  }
  cmdPrev = cmd;
  g_state = st;
  duty = roundf(duty * 10.0f) / 10.0f;
  if (duty != g_duty) setDuty(duty);
}

// --- 1秒窓の統計 ---
static uint16_t curHist[HIST_MAX];
static uint32_t curSum = 0, curMax = 0, vdSum = 0, vdN = 0, lateCnt = 0;
static int      winPos = 0, ledW = 0;
static float    potF = 0.0f;
static int      potCnt = 0;

static void windowEnd() {
  uint32_t need = (uint32_t)(N * TOP_FRAC), acc = 0, topmV = curMax;
  for (int v = HIST_MAX - 1; v >= 0; v--) {
    acc += curHist[v];
    if (acc >= need) { topmV = v; break; }
  }
  const float k = 1.0f / 1000.0f / SHUNT_OHM;
  float vdmv = (vdN > 0) ? ((float)vdSum / (float)vdN) : 0.0f;
  float vd = VCAL_GAIN * (vdmv / 1000.0f * VDIV) + VCAL_OFFSET;

  g_win.seq++;
  __sync_synchronize();
  g_win.ipk  = topmV * k;
  g_win.imax = curMax * k;
  g_win.iavg = (curSum / (float)N) * k;
  g_win.vd   = vd;
  g_win.late = lateCnt;
  __sync_synchronize();
  g_win.seq++;

  g_ledReady = ledW;                        // LED解析タスクへ
  ledW ^= 1;
  memset(curHist, 0, sizeof(curHist));
  curSum = 0; curMax = 0; vdSum = 0; vdN = 0; lateCnt = 0;
}

void loop() {
  static uint32_t slotUs = 0;
  static bool slotInit = false;
  if (!slotInit) { slotUs = micros() + SLOT_US; slotInit = true; }
  else slotUs += SLOT_US;
  if ((int32_t)(micros() - slotUs) > (int32_t)(SLOT_US / 2)) lateCnt++;   // 遅れたスロット数
  while ((int32_t)(micros() - slotUs) < 0) { }

  uint16_t vt = analogRead(PIN_TACH);
  uint16_t vl = analogRead(PIN_LED);
  uint32_t mc = analogReadMilliVolts(PIN_CUR);
  uint32_t ph = sIdx & 7;
  if ((ph & 3) == 1) {                      // POT: 1kHzで読み、1次フィルタで平均化
    float mv = (float)analogReadMilliVolts(PIN_POT);
    if (potCnt < POT_INIT_N) {
      potCnt++;
      potF += (mv - potF) / potCnt;
      if (potCnt == POT_INIT_N) g_potReady = true;
    } else {
      potF += (mv - potF) * POT_ALPHA;
    }
    g_potMv = potF;
  }
  if (ph == 3) { vdSum += analogReadMilliVolts(PIN_VFAN); vdN++; }   // VD: 500Hz

  tachSample(vt);
  ledBuf[ledW][winPos] = vl;
  uint32_t m = (mc >= (uint32_t)HIST_MAX) ? (uint32_t)(HIST_MAX - 1) : mc;
  curHist[m]++;
  curSum += m;
  if (m > curMax) curMax = m;

  if ((sIdx % (FS * CTRL_MS / 1000)) == 0) controlStep();
  if (++winPos >= N) { winPos = 0; windowEnd(); }
  sIdx++;
}

// ================== UI (core0) ==================
static char     inBuf[16];
static int      inLen = 0;
static bool     setPending = false;
static uint32_t lastSetPrint = 0;
static float    reqRpm = 0.0f;
static float    potCmdRef = 0.0f;
static bool     potStopped = false, potInit = false;

// 指示値を適用。0<要求<RPM_RUN_MIN は RPM_RUN_MIN にする
static void applyCmd(float req, const char *src) {
  float eff = 0.0f;
  if (req > 0.0f) {
    eff = req;
    if (eff < RPM_RUN_MIN) eff = RPM_RUN_MIN;
    if (eff > RPM_MAX) eff = RPM_MAX;
  }
  reqRpm = req;
  g_cmdSrc = src;
  g_rpmCmd = eff;
  setPending = true;
}

static void handleSerial() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') {
      if (inLen > 0) {
        inBuf[inLen] = 0;
        char *e;
        float v = strtof(inBuf, &e);
        if (e != inBuf && v >= 0.0f && v <= RPM_MAX) applyCmd(v, "SERIAL");
        else Serial.println("? 回転数は 0〜2500 の数値で入力してください");
        inLen = 0;
      }
    } else if (inLen < (int)sizeof(inBuf) - 1) {
      inBuf[inLen++] = c;
    }
  }
}

// POT -> 指示値。停止ゾーン + 量子化 + ヒステリシス
static void servicePotCmd() {
  if (!g_potReady) return;
  float mv = g_potMv;
  float frac = (mv - POT_MV_LO) / (float)(POT_MV_HI - POT_MV_LO);
  if (frac < 0.0f) frac = 0.0f;
  if (frac > 1.0f) frac = 1.0f;
  if (!potInit) potStopped = (frac < POT_STOP_EXIT);
  else if (potStopped) { if (frac >= POT_STOP_EXIT) potStopped = false; }
  else if (frac <= POT_STOP_ENTER) potStopped = true;

  float raw = 0.0f;
  if (!potStopped) {
    float f2 = (frac - POT_STOP_EXIT) / (1.0f - POT_STOP_EXIT);
    if (f2 < 0.0f) f2 = 0.0f;
    raw = RPM_POT_MIN + f2 * (RPM_MAX - RPM_POT_MIN);
  }
  float q = roundf(raw / RPM_STEP) * RPM_STEP;

  if (!potInit) {
    potInit = true;
    potCmdRef = q;
    applyCmd(q, "POT");
    return;
  }
  bool edgeCase = (q == 0.0f) != (potCmdRef == 0.0f);                       // 停止 <-> 動作
  bool atLimit  = (q <= RPM_POT_MIN || q >= RPM_MAX) && q != potCmdRef;     // 上下端に確実に届かせる
  if (edgeCase || atLimit || fabsf(raw - potCmdRef) >= POT_HYST_RPM) {
    potCmdRef = q;
    applyCmd(q, "POT");
  }
}

static void printSet() {
  if (setPending && millis() - lastSetPrint >= 200) {
    if (reqRpm > 0.0f && reqRpm != g_rpmCmd)
      Serial.printf("SET RPM= %.0f (%s, 要求 %.0f)  POT= %.0fmV\n", (float)g_rpmCmd, (const char *)g_cmdSrc, reqRpm, (float)g_potMv);
    else
      Serial.printf("SET RPM= %.0f (%s)  POT= %.0fmV\n", (float)g_rpmCmd, (const char *)g_cmdSrc, (float)g_potMv);
    setPending = false;
    lastSetPrint = millis();
  }
}

static bool readWin(WinStat &w) {
  for (int i = 0; i < 4; i++) {
    uint32_t s1 = g_win.seq;
    __sync_synchronize();
    w.ipk = g_win.ipk; w.imax = g_win.imax; w.iavg = g_win.iavg; w.vd = g_win.vd; w.late = g_win.late;
    __sync_synchronize();
    uint32_t s2 = g_win.seq;
    if (s1 == s2 && !(s1 & 1)) { w.seq = s1; return true; }
  }
  return false;
}

static void printStatus(const WinStat &w) {
  float fanv = w.vd - w.iavg * SHUNT_OHM;
  g_fanv = fanv;
  g_iavg = w.iavg;
  float sim = SIM_K * fanv - SIM_OFS;
  if (sim < 0) sim = 0;
  Serial.printf("CMD= %.0frpm(%s)  DUTY= %.1f%%  POT= %.0fmV  FAN_V= %.2fV  VD= %.2fV  ",
                (float)g_rpmCmd, (const char *)g_cmdSrc, (float)g_duty, (float)g_potMv, fanv, w.vd);
  if (g_thValid) Serial.printf("TACH= %.0frpm  ", (float)g_tachRpm); else Serial.print("TACH=---  ");
  if (g_ledRpm >= 0) Serial.printf("LED= %.0frpm  ", (float)g_ledRpm); else Serial.print("LED=---  ");
  Serial.printf("SIM= %.0frpm  IPK= %.3fA  IMAX= %.3fA  IAVG= %.3fA  LATE= %lu  ST= %s\n",
                sim, w.ipk, w.imax, w.iavg, (unsigned long)w.late, ST_NAME[g_state]);
}

static void uiTask(void *) {
  uint32_t lastSeq = 0;
  for (;;) {
    handleSerial();
    servicePotCmd();
    printSet();
    WinStat w;
    if (g_win.seq != lastSeq && readWin(w)) {
      lastSeq = w.seq;
      printStatus(w);
    }
    vTaskDelay(pdMS_TO_TICKS(10));
  }
}

// ================== LED解析 (参考表示, core0) ==================
// バッファの1秒波形から主周波数を求め、perRev(波数/回転)でRPMに換算。不成立なら -1
static float analyzeSpectrum(const uint16_t *buf, int perRev, bool removeMains) {
  double s = 0;
  for (int n = 0; n < N; n++) s += buf[n];
  float mean = (float)(s / N);
  for (int n = 0; n < N; n++) x[n] = buf[n] - mean;

  if (removeMains) {
    float a[HARM + 1], b[HARM + 1];
    for (int k = 1; k <= HARM; k++) {
      uint32_t step = (uint32_t)MAINS * k, idx = 0;
      double sc = 0, ss = 0;
      for (int n = 0; n < N; n++) {
        sc += x[n] * cosT(idx);
        ss += x[n] * sinTab[idx];
        idx += step; if (idx >= (uint32_t)FS) idx -= FS;
      }
      a[k] = (float)(2.0 * sc / N);
      b[k] = (float)(2.0 * ss / N);
    }
    for (int k = 1; k <= HARM; k++) {
      uint32_t step = (uint32_t)MAINS * k, idx = 0;
      for (int n = 0; n < N; n++) {
        x[n] -= a[k] * cosT(idx) + b[k] * sinTab[idx];
        idx += step; if (idx >= (uint32_t)FS) idx -= FS;
      }
    }
  }
  for (int n = 0; n < N; n++) x[n] *= 0.5f - 0.5f * cosT(n);

  double msum = 0; int pk = 0; float pkv = 0;
  for (int f = FMIN; f <= FMAX; f++) {
    uint32_t idx = 0;
    float re = 0, im = 0;
    for (int n = 0; n < N; n++) {
      re += x[n] * cosT(idx);
      im += x[n] * sinTab[idx];
      idx += f; if (idx >= (uint32_t)FS) idx -= FS;
    }
    float m = sqrtf(re * re + im * im);
    mag[f - FMIN] = m;
    msum += m;
    if (m > pkv) { pkv = m; pk = f - FMIN; }
  }
  float avg = (float)(msum / (FMAX - FMIN + 1));
  if (pk <= 0 || pk >= FMAX - FMIN) return -1;
  if (avg <= 0 || pkv / avg < QUALITY_MIN) return -1;

  float ma = mag[pk - 1], mb = mag[pk], mc = mag[pk + 1];
  float den = ma - 2 * mb + mc;
  float d = (den != 0) ? 0.5f * (ma - mc) / den : 0;
  float fpk = FMIN + pk + d;
  return fpk / perRev * 60.0f;
}

static void ledTask(void *) {
  for (;;) {
    int b = g_ledReady;
    if (b >= 0) {
      g_ledReady = -1;
      g_ledRpm = analyzeSpectrum(ledBuf[b], BLADES, true);
    }
    vTaskDelay(pdMS_TO_TICKS(20));
  }
}

// ================== OLED (core0) ==================
static void drawOled() {
  char buf[32];
  int tach = (int)(g_tachRpm + 0.5f);
  float cmd = g_rpmCmd;
  u8g2.clearBuffer();

  // 1行目: 見出し / 指示の入力元 / 状態
  u8g2.setFont(u8g2_font_5x8_tf);
  u8g2.drawStr(0, 0, "TACH");
  u8g2.drawStr(34, 0, (const char *)g_cmdSrc);
  const char *stn = ST_NAME[g_state];
  u8g2.drawBox(92, 0, 36, 9);
  u8g2.setDrawColor(0);
  u8g2.drawStr(92 + (36 - (int)u8g2.getStrWidth(stn)) / 2, 0, stn);
  u8g2.setDrawColor(1);

  // 大きく TACH の回転数
  u8g2.setFont(u8g2_font_logisoso24_tn);
  snprintf(buf, sizeof(buf), "%d", tach);
  u8g2.drawStr(0, 10, buf);
  int w = u8g2.getStrWidth(buf);
  u8g2.setFont(u8g2_font_6x12_tf);
  u8g2.drawStr(w + 4, 22, "rpm");

  // 指示値 / Duty
  snprintf(buf, sizeof(buf), "SET %4.0frpm", cmd);
  u8g2.drawStr(0, 36, buf);
  snprintf(buf, sizeof(buf), "D%4.1f%%", (float)g_duty);
  u8g2.drawStr(88, 36, buf);

  // バー: 塗り=TACH, 縦線=指示値 (0〜2500rpm)
  u8g2.drawFrame(0, 49, 128, 6);
  int fill = (int)(126.0f * tach / RPM_MAX);
  if (fill < 0) fill = 0;
  if (fill > 126) fill = 126;
  if (fill > 0) u8g2.drawBox(1, 50, fill, 4);
  if (cmd > 0) {
    int tx = 1 + (int)(126.0f * cmd / RPM_MAX);
    if (tx > 126) tx = 126;
    u8g2.drawVLine(tx, 46, 10);
  }

  // 下段: ファン電圧 / 電流 / 誤差
  u8g2.setFont(u8g2_font_5x8_tf);
  snprintf(buf, sizeof(buf), "%.1fV %.2fA", (float)g_fanv, (float)g_iavg);
  u8g2.drawStr(0, 56, buf);
  if (cmd > 0 && g_thValid) {
    snprintf(buf, sizeof(buf), "e%+d", (int)(cmd - tach));
    u8g2.drawStr(128 - (int)u8g2.getStrWidth(buf), 56, buf);
  }
  u8g2.sendBuffer();
}

static void oledTask(void *) {
  u8g2.begin();
  u8g2.setBusClock(400000);
  u8g2.setFontPosTop();
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_6x12_tf);
  u8g2.drawStr(0, 20, "FAN RPM CONTROL");
  u8g2.drawStr(0, 36, "Step3");
  u8g2.sendBuffer();
  vTaskDelay(pdMS_TO_TICKS(1000));
  for (;;) {
    drawOled();
    vTaskDelay(pdMS_TO_TICKS(250));
  }
}

// ================== setup ==================
void setup() {
  Serial.begin(115200);
  analogSetPinAttenuation(PIN_POT,  ADC_11db);
  analogSetPinAttenuation(PIN_VFAN, ADC_11db);
  analogSetPinAttenuation(PIN_TACH, ADC_11db);
  analogSetPinAttenuation(PIN_LED,  ADC_2_5db);
  analogSetPinAttenuation(PIN_CUR,  ADC_6db);
  for (int i = 0; i < FS; i++) sinTab[i] = sinf(2.0f * PI * i / FS);

#if ESP_ARDUINO_VERSION_MAJOR >= 3
  ledcAttach(PIN_PWM, PWM_FREQ, PWM_BITS);
#else
  ledcSetup(PWM_CH, PWM_FREQ, PWM_BITS);
  ledcAttachPin(PIN_PWM, PWM_CH);
#endif
  setDuty(0.0f);

  Serial.println("回転数指示: POT(下端=停止) / シリアル 0〜2500 + Enter (例: 1200)");
  xTaskCreatePinnedToCore(uiTask,   "ui",   8192, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(oledTask, "oled", 6144, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(ledTask,  "led",  6144, NULL, 1, NULL, 0);
}

参考

DCファン駆動回路の実験:ハイサイドPch MOSFET編

はじめに

既報ローサイドMOSFETで駆動するDCファンの回転数・電流測定の実験では、DCファンのローサイドにMOSFETを配置した駆動回路での測定実験を行いました。

ローサイド駆動回路では、ファン電圧を制御する際にファンのGND電位が浮き上がってしまい、タコ信号(TACH)のLowレベルが上昇してESP32等のマイコンでパルスを正常に読み取れなくなる課題がありました。

今回は、ハイサイドにPch MOSFETを配置した回路を実験します。 ハイサイド駆動ではファンのGNDが常に回路の共通GNDに固定されるため、ファン電圧を極限まで絞った低回転時であっても、モータが回転している限り完全な振幅のTACH信号が得られるのが最大のメリットです。

TPC8408の特性とMOSFET比較

小信号用のMOSFETの手持ちがありませんでしたので、手元にあったパワーMOSFET TPC8408 のPch部を使うことにします。主要なMOSFETのスペックを並べると、TPC8408は2N7000やBS250Pなどの小信号FETに比べて「しきい値電圧 \(V_{GS(th)}\)」と「ON抵抗 \(R_{DS(on)}\)」が非常に低いことが分かります。

  • 低いしきい値電圧 (\(V_{GS(th)}\)): しきい値電圧が低いほど、より低いゲート電圧(=低い温度)からファンの回転が始まります。
  • 極小のON抵抗 (\(R_{DS(on)}\)): 2N7000やBS250Pなどの小信号FETではON抵抗(数Ω)による電圧降下でフル回転時の最高回転数が落ちてしまいますが、TPC8408(33mΩ)では印加ロスがほぼゼロとなり、12V電源直結時に近い定格回転数を確保できます。

一方、TPC8408は 相互コンダクタンス (\(g_m\)) が \(3\sim 4\,\text{S}\) とBS250Pの30〜40倍近く大きいため、ゲート電圧の微小な変化でドレイン電流が大きく動きます。そのため、ソース抵抗による電流負帰還を入れても、温度変化に対して回転数の立ち上がりが急峻(ON/OFF的)になる特徴があります。(*注)

デュアルMOSFET Nch+Pch TPC8408

項目 BS250P AO3401A(P) TPC8408 Pch 2N7000 AO3400(N) TPC8408 Nch
極性 P P P N N N
VDSS −45 V −30 V −40 V 60 V 30 V 40 V
ID (DC) −0.18 A −4.2 A −5.3 A 0.2 A 5.8 A 6.1 A
RDS(on) typ ≈6 Ω 55 mΩ @−10 V 33 mΩ @−10 V ≈5 Ω 20〜30 mΩ @10 V 24 mΩ @10 V
VGS(th) −2〜−4 V −1〜−3 V −0.8〜−2.0 V 2–3 V 0.65–1.45 V 1.3–2.3 V
VGSS(max) ±20 V ±12 V ±20 V ±20 V ±12 V ±20 V
パッケージ TO-92 SOT-23 SOP-8 TO-92 SOT-23 SOP-8
gm(代表値) 0.05〜0.1 S
極めて小さい
1.5〜2.0 S
中型FETの典型
3〜4 S
大電流FETの高値
0.1〜0.2 S
小さい
2.5〜3.5 S
中型FETの高値
4〜5 S
大電流FETの最大級

※ gmは本DCファンを駆動する条件で置き換えた値

Gate電圧の可変方法

既報では、NTNサーミスタの代用に5~10kΩのトリマ抵抗を使ってハンドで抵抗を変えることで、Gate電圧を変更していました。今回はプログラム電源を使って、Gate電圧を直接変更します。

Gate電圧を11Vから10Vまで時間の経過に応じてランプ状に変化させます。このとき、サーミスタ相当の抵抗値RTHがグラフのように変わります。

Gate電圧の変更
Gate電圧の変更

回路図とシミュレーション

回路はNch用のレールをVCC側に持ってきたハイサイド構成で、基本の制御トポロジーはNch回路と同等です。 TPC8408のモデルは東芝公式 から持ってきています。ただし、しきい値電圧は使用した個体を実測した値に変更しています。

  • Datasheet \(V_{TH}: -0.8\sim -2.0\,V\)
  • 公式モデル \(V_{TH0}: -1.775\,V\)
  • 実測 \(V_{TH0}: -1.075\,V\)

回路図とシミュレーション結果を載せます。43℃ @4.71kΩから回転が始まり、50℃ @3.57kΩ でほぼフル回転になります。ソース抵抗による電流負帰還を2Ωから10Ωに増やしても、この急峻さは変わりませんでした。

Pch MOSFET FANモータ駆動回路
Pch MOSFET FANモータ駆動回路

実験結果

低回転時

ローサイド駆動ではタコ信号が潰れていた330rpmのような極低回転域であっても、ハイサイド駆動ではファンのGNDが浮かないため、TACH信号は H: 3.2V、L: \(\approx\)0V のクッキリとしたパルスが得られます。マイコン(ESP32)のGPIOでも確実に回転数を認識できています。

低回転
低回転

PMIS\_TPC8408 低回転
16:31:06.311 -> FAN\_V= 3.75V  VD= 3.89V  TACH= 352rpm  LED= 352rpm  IRPM=---  DRPM= 704rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.763V  VGPP= 0.079V  RTH= 4217ohm  T= 45.9C  TS= 999963us
16:31:08.373 -> FAN\_V= 3.76V  VD= 3.90V  TACH= 349rpm  LED= 350rpm  IRPM=---  DRPM= 1051rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.759V  VGPP= 0.075V  RTH= 4192ohm  T= 46.0C  TS= 999960us
16:31:10.472 -> FAN\_V= 3.75V  VD= 3.89V  TACH= 332rpm  LED= 333rpm  IRPM=---  DRPM= 1331rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.760V  VGPP= 0.310V  RTH= 4202ohm  T= 46.0C  TS= 999960us
16:31:12.539 -> FAN\_V= 3.75V  VD= 3.89V  TACH= 337rpm  LED= 337rpm  IRPM=---  DRPM= 1349rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.762V  VGPP= 0.075V  RTH= 4211ohm  T= 45.9C  TS= 999960us
16:31:14.571 -> FAN\_V= 3.76V  VD= 3.90V  TACH= 350rpm  LED= 350rpm  IRPM=---  DRPM= 1050rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.760V  VGPP= 0.087V  RTH= 4200ohm  T= 46.0C  TS= 999965us
16:31:16.650 -> FAN\_V= 3.76V  VD= 3.91V  TACH= 353rpm  LED= 353rpm  IRPM=---  DRPM= 705rpm  IPK= 0.071A  IMAX= 0.071A  IAVG= 0.071A  VG= 10.761V  VGPP= 0.083V  RTH= 4209ohm  T= 45.9C  TS= 999960us

フル回転時

2400rpmが定格回転だと思われます。TPC8408の超低オン抵抗(33mΩ)の効果により、ファン印加電圧は11.4V(電源11.6Vに対してわずか0.2V降下)に達しており、12V直結と同等のパフォーマンスを発揮できています。 TACH, LED, シャント脈動(IRPM)、ドレイン脈動(DRPM)の4方式とも同じ約2420rpmを示しており、測定の正しさが証明されています。

フル回転
フル回転

PMOS\_TPC8408 定格回転
16:33:47.803 -> FAN\_V= 11.40V  VD= 11.60V  TACH= 2422rpm  LED= 2422rpm  IRPM= 2422rpm  DRPM= 2423rpm  IPK= 0.149A  IMAX= 0.156A  IAVG= 0.099A  VG= 10.442V  VGPP= 0.145V  RTH= 2718ohm  T= 57.6C  TS= 999959us
16:33:49.878 -> FAN\_V= 11.40V  VD= 11.60V  TACH= 2421rpm  LED= 2421rpm  IRPM= 2421rpm  DRPM= 2421rpm  IPK= 0.149A  IMAX= 0.157A  IAVG= 0.099A  VG= 10.442V  VGPP= 0.145V  RTH= 2718ohm  T= 57.5C  TS= 999963us
16:33:51.934 -> FAN\_V= 11.41V  VD= 11.61V  TACH= 2419rpm  LED= 2419rpm  IRPM= 2419rpm  DRPM= 2417rpm  IPK= 0.148A  IMAX= 0.246A  IAVG= 0.099A  VG= 10.440V  VGPP= 2.625V  RTH= 2713ohm  T= 57.6C  TS= 999960us
16:33:54.006 -> FAN\_V= 11.44V  VD= 11.64V  TACH= 2418rpm  LED= 2419rpm  IRPM= 2419rpm  DRPM= 2418rpm  IPK= 0.149A  IMAX= 0.158A  IAVG= 0.099A  VG= 10.439V  VGPP= 0.141V  RTH= 2708ohm  T= 57.7C  TS= 999961us
16:33:56.109 -> FAN\_V= 11.38V  VD= 11.58V  TACH= 2418rpm  LED= 2419rpm  IRPM= 2418rpm  DRPM= 2418rpm  IPK= 0.148A  IMAX= 0.156A  IAVG= 0.098A  VG= 10.431V  VGPP= 0.170V  RTH= 2682ohm  T= 57.9C  TS= 999966us
16:33:58.154 -> FAN\_V= 11.41V  VD= 11.60V  TACH= 2415rpm  LED= 2415rpm  IRPM= 2415rpm  DRPM= 2415rpm  IPK= 0.146A  IMAX= 0.302A  IAVG= 0.099A  VG= 10.429V  VGPP= 2.641V  RTH= 2675ohm  T= 58.0C  TS= 999961us

シミュレーションとの比較

温度上昇時には約2000rpmで一気に回り始めますが、温度下降時には500rpm付近まで低速回転を維持しながら粘り強く回り続けるヒステリシス特性が見られます。低回転域の動作において、実測値はシミュレーション結果と非常に良く一致しています。

温度変化に対する回転の立ち上がり(傾き)については、シミュレーションの方がやや急峻に出ましたが、その差は僅かでありほぼ一致とみなせます。

測定方式の比較

LEDの起電力方式(光起電力検出)を基準として、TACH(パルス)、シャント方式(IRPM)、ドレイン方式(DRPM)の測定結果を比較・検証しました。

  • 測定の正確性: 各方式とも検出可能範囲内であれば正しい回転数を正確に検出できています。
  • 各方式の測定可能限界範囲:
    • LED起電力方式: 0rpm〜全域(完全無制限)
    • TACH(タコパルス): 500rpm〜
    • DRPM(ドレイン電圧脈動): 700rpm〜
    • IRPM(シャント電流脈動): 1500rpm〜


実験に使用したESP32測定用スケッチ

今回の実験で、ファン電圧・タコパルス・LED起電力・電流脈動・ドレイン脈動・ゲート電圧・サーミスタ温度を一括測定するために作成したESP32用スケッチです。NMOS、PMOSのどちらにも使える汎用スケッチですが、それぞれのパラメータを正しく書き換える必要はあります。

fan_sens.ino

// ESP32: FANの電圧・回転数・電流・サーミスタ温度を測り、シリアルに1行で出力します。
// ピンはすべて GPIO番号で書きます (ボード表示の D25 は GPIO25 のことです)。
//   GPIO25 : MOSFETのドレイン電圧 VD (分圧 11.82V -> 1.812V)。ドレイン電圧の脈動から回転数 (DRPM)
//   GPIO4  : FANのタコ信号 (2パルス/回転)。タコ線のないファンでは "---"
//   GPIO34 : LED起電力 (羽根枚数 BLADES) から回転数 (LED)
//   GPIO35 : シャント抵抗R1 (2Ω) の電圧 -> 電流。電流の脈動から回転数 (IRPM)
//   GPIO33 : サーミスタ分圧の出力 Vg (抵抗2本で分圧して入力) -> Rth, 温度T
//
// ■ FAN_V と VD
//   VD    : ドレイン電圧 (GND基準)
//   FAN_V : FANの+端子と-端子の間の電圧
//             Nch: FAN+ = VCC,        FAN- = ドレイン           -> FAN_V = VCC - VD
//             Pch: FAN+ = ドレイン,   FAN- = R1の上端 (シャント電圧) -> FAN_V = VD - 電流 * R1
//
// ■ Vg -> Rth -> 温度T   (Nch と Pch の違いは、分子だけです)
//   サーミスタ分圧は、「Rth と Rs (直列の2本)」と「Rv (別の1本)」の組み合わせです。
//     Vg = VCC * 分子 / (Rv + Rth + Rs)
//     Nch (GATE_PCH = false): 分子 = Rv         VCC --[Rs]--[Rth]-- Vg --[Rv]-- GND   (レール = GND)
//     Pch (GATE_PCH = true ): 分子 = Rth + Rs   VCC --[Rv]-- Vg --[Rth]--[Rs]-- GND   (レール = VCC)
//   (分子は、どちらも Vg と GND の間にある抵抗です。)
//
// ■ GPIO33 の分圧
//   Vg は最大で VCC まで上がり、ESP32のピンに入れられる電圧(3.3V)を超えます。そこで、固定抵抗2本で分圧して GPIO33 に入れます。
//     VG_RUP : Vg 側の抵抗      VG_RGND : GND 側の抵抗
//     ピン電圧 = Vg * VG_RGND / (VG_RUP + VG_RGND)   分圧比 VG_DIV = (VG_RUP + VG_RGND) / VG_RGND  (Nch, Pch 共通)
//     ピン電圧の最大 (Vg = VCC のとき) = VCC / VG_DIV が、約3V以下になるようにします。
//   この分圧抵抗は、Vg と GND の間 (= 分子の抵抗) に並列に入るため、その分を計算で補正します。
//
// ■ 設定
//   GATE_PCH, RS_OHM, RV_OHM, VCC_MEAS, VG_RUP, VG_RGND を、実機の値(実測)にします。
//   ESP32を接続する前に、GPIO33のピン電圧(最大時)が3V以下であることを、DMMで確認してください。
#include <Arduino.h>
#include <math.h>

const int   PIN_VFAN = 36;
const int   PIN_TACH = 32;
const int   PIN_LED  = 34;
const int   PIN_CUR  = 35;      // 仮。実際の配線に合わせて変更
const int   PIN_VG   = 33;      // Vg (分圧後)

const float VDIV       = 6.52f;     // 11.82V / 1.812V
const float VCAL_GAIN  = 0.9634f;   // DMM実測2点から算出
const float VCAL_OFFSET = -0.001f;  // [V]
const float SHUNT_OHM  = 2.0f;
const float TOP_FRAC   = 0.02f;     // IPK: 生サンプルの上位2%点 (オシロのTop相当の近似)
const int   TACH_PPR   = 2;
const int   BLADES     = 9;        // ファンの羽根枚数 (12V旧ファン=9, 5Vファン=11)
const int   CUR_RIPPLES_PER_REV = 4; // 電流波形の波数/回転 (5Vファンのオシロ実測: 4波/回転)
const int   DRIPPLES_PER_REV   = 4; // ドレイン電圧脈動の波数/回転 (オシロ約4.7ms間隔=約3190rpm相当から4波/回転と推定。要確認)

// タコ検出 (ADC + ヒステリシス + 粗周期 + ブランキング)
const float TACH_LEVEL_FRAC = 0.01f; // Low/High推定に使う下位/上位の割合 (1%)
const float TACH_TH_LOW     = 0.35f; // 立ち下がり判定 (Low + 35% x 振幅)
const float TACH_TH_HIGH    = 0.65f; // 立ち上がり判定 (Low + 65% x 振幅)
const int   TACH_MIN_AMP    = 300;   // 振幅(ADC生値)がこれ未満なら "---"  (実測の振幅は768〜896)
const int   TF_MIN          = 5;     // 粗周期DFTの検索範囲 [Hz] (5..300Hz = 150..9000rpm)
const int   TF_MAX          = 300;
const float TF_PEAK_FRAC    = 0.6f;  // 最大ピークのこの割合以上の局所ピークのうち最低周波数を基本波とする
const float TACH_BLANK      = 0.6f;  // 採用した立ち上がりの後、周期のこの割合の間は次を無視

// Vg -> Rth -> 温度T  (説明はファイル先頭)。現在の設定: Pch駆動、12V回路の値
const bool  GATE_PCH   = true;      // false = Nch駆動回路, true = Pch駆動回路
const float VG_RUP     = 14560.0f;  // GPIO33の分圧: Vg側の抵抗 [ohm] (提案値。実測値に合わせる)
const float VG_RGND    = 4637.0f;  // GPIO33の分圧: GND側の抵抗 [ohm] (提案値。実測値に合わせる)
const float VG_DIV     = (VG_RUP + VG_RGND) / VG_RGND;
const float RS_OHM     = 980.0f;      // Rs実測 [ohm] 2N7000 6V:0, 12V:978 1963.0, TPC8408_P 12V 980
const float RV_OHM     = 436.0f;   // Rv実測 [ohm] 2N7000 6V:2000, 12V:986, TPC8408_p 12V 436
const float VCC_MEAS   = 11.91f;     // 電源電圧 実測 [V] 6V:5.93, 12V:11.91 (サーミスタ分圧とFANの電源)
// サーミスタ温度: Steinhart-Hart式 1/T = A + B*ln(R) + C*ln(R)^3  (係数はLTspice回路図の As, Bs, Cs と同じ値)
const float SH_A       = 1.129148e-3f;
const float SH_B       = 2.34125e-4f;
const float SH_C       = 8.76741e-8f;

const int   FS        = 4000;   // サンプリング周波数 [Hz] (1スロット250us。ADC5回読みで約216usかかるため5000Hzでは間に合わない)
const int   N         = 4000;   // 1.000秒 (60Hzがちょうど60周期)
const int   MAINS     = 60;     // 商用周波数 [Hz]
const int   HARM      = 12;     // 除去する高調波次数 (60..720Hz)
const int   FMIN      = 40;     // 検索範囲 [Hz]
const int   FMAX      = 800;
const float QUALITY_MIN = 6.0f; // ピーク/平均 がこれ未満なら "---"
const int   HIST_MAX  = 2500;   // 電流ヒストグラム上限 [mV]

static uint16_t rawBuf[N];      // LED ADC生値
static uint16_t tachBuf[N];     // タコ線 ADC生値
static uint16_t curBuf[N];      // シャント電圧 [mV]
static uint16_t drainBuf[N];    // GPIO25 (ドレイン分圧) ADC生値
static uint16_t hist[HIST_MAX]; // シャント電圧ヒストグラム (1mV刻み)
static float    x[N];
static float    sinTab[FS];     // sin(2*pi*i/FS)
static float    mag[FMAX - FMIN + 1];
static float    tm[TF_MAX - TF_MIN + 1];

inline float cosT(uint32_t i) { return sinTab[(i + FS / 4) % FS]; }

// ドレイン電圧 VD [V] (GND基準)。64回平均し、DMMとの2点で校正
float readDrainVoltage() {
  uint32_t sum = 0;
  for (int i = 0; i < 64; i++) sum += analogReadMilliVolts(PIN_VFAN);
  float v = (sum / 64.0f) / 1000.0f * VDIV;
  return VCAL_GAIN * v + VCAL_OFFSET;
}

// Vg -> Rth [ohm], 温度 [degC] (Steinhart-Hart)。不成立なら false
//   x = Vg / VCC,   Vg = VCC * 分子 / (Rv + Rth + Rs)
//   Nch: 分子 = Rv          -> Rth + Rs = Rv * (1 - x) / x
//   Pch: 分子 = Rth + Rs    -> Rth + Rs = Rv * x / (1 - x)
//   GPIO33の分圧 (合計 Rdiv) は分子の抵抗に並列に入るので、分子を (分子 || Rdiv) として解く
bool calcRthTemp(float vg, float &rth, float &tc) {
  if (vg < 0.1f || vg >= VCC_MEAS) return false;
  const float rdiv = VG_RUP + VG_RGND;
  float x = vg / VCC_MEAS;
  float rthrs;                                         // Rth + Rs
  if (!GATE_PCH) {
    float rn = RV_OHM * rdiv / (RV_OHM + rdiv);        // 分子 (Rv || Rdiv)
    rthrs = rn * (1.0f - x) / x;
  } else {
    float rn = RV_OHM * x / (1.0f - x);                // 分子 ((Rth+Rs) || Rdiv)
    if (rn >= rdiv) return false;
    rthrs = rn * rdiv / (rdiv - rn);                   // 並列を外して Rth + Rs に戻す
  }
  rth = rthrs - RS_OHM;
  if (rth <= 0) return false;
  float lnR = logf(rth);
  tc = 1.0f / (SH_A + SH_B * lnR + SH_C * lnR * lnR * lnR) - 273.15f;   // Steinhart-Hart
  return true;
}

// タコ波形の基本周波数 [Hz] を1Hz刻みのDFTで求める。不成立なら 0
float tachCoarseHz() {
  double s = 0;
  for (int n = 0; n < N; n++) s += tachBuf[n];
  float mean = (float)(s / N);
  for (int n = 0; n < N; n++) x[n] = ((float)tachBuf[n] - mean) * (0.5f - 0.5f * cosT(n));

  float mx = 0;
  for (int f = TF_MIN; f <= TF_MAX; f++) {
    uint32_t idx = 0;
    float re = 0, im = 0;
    for (int n = 0; n < N; n++) {
      re += x[n] * cosT(idx);
      im += x[n] * sinTab[idx];
      idx += f; if (idx >= FS) idx -= FS;
    }
    float m = sqrtf(re * re + im * im);
    tm[f - TF_MIN] = m;
    if (m > mx) mx = m;
  }
  if (mx <= 0) return 0;
  int cnt = TF_MAX - TF_MIN + 1;
  for (int i = 1; i < cnt - 1; i++) {
    if (tm[i] >= TF_PEAK_FRAC * mx && tm[i] >= tm[i - 1] && tm[i] >= tm[i + 1]) return (float)(TF_MIN + i);
  }
  return 0;
}

// タコ: 1秒窓のADC生値からRPMを算出。不成立なら -1
float analyzeTach() {
  uint16_t h[64] = {0};                       // 粗いヒストグラム (64段階)
  for (int i = 0; i < N; i++) h[tachBuf[i] >> 6]++;
  uint32_t need = (uint32_t)(N * TACH_LEVEL_FRAC), acc = 0;
  int lo = 0, hi = 4095;
  for (int b = 0; b < 64; b++)   { acc += h[b]; if (acc >= need) { lo = b * 64 + 32; break; } }
  acc = 0;
  for (int b = 63; b >= 0; b--)  { acc += h[b]; if (acc >= need) { hi = b * 64 + 32; break; } }
  float amp = (float)(hi - lo);
  if (amp < TACH_MIN_AMP) return -1;
  float thL = lo + TACH_TH_LOW  * amp;
  float thH = lo + TACH_TH_HIGH * amp;

  float fc = tachCoarseHz();
  if (fc <= 0) return -1;
  float minGap = TACH_BLANK * FS / fc;        // [sample]

  bool state = tachBuf[0] > thH;
  int cnt = 0;
  float tFirst = 0, tLast = 0;
  for (int i = 1; i < N; i++) {
    float v = tachBuf[i];
    if (!state && v > thH) {                  // 立ち上がり: 交差点を線形補間
      state = true;
      float prev = tachBuf[i - 1];
      float t = (i - 1) + (thH - prev) / (v - prev);
      if (cnt == 0 || (t - tLast) >= minGap) {
        if (cnt == 0) tFirst = t;
        tLast = t;
        cnt++;
      }
    } else if (state && v < thL) {
      state = false;
    }
  }
  if (cnt < 3 || tLast <= tFirst) return -1;
  float sec = (tLast - tFirst) / FS;
  return (cnt - 1) / sec * 60.0f / TACH_PPR;
}

// 電流 [A]: top=上位TOP_FRAC点, vmax=最大値, avg=平均値
void analyzeCurrent(float &top, float &vmax, float &avg) {
  memset(hist, 0, sizeof(hist));
  uint32_t sum = 0, mx = 0;
  for (int i = 0; i < N; i++) {
    uint32_t v = curBuf[i];
    if (v >= HIST_MAX) v = HIST_MAX - 1;
    hist[v]++;
    sum += v;
    if (v > mx) mx = v;
  }
  uint32_t need = (uint32_t)(N * TOP_FRAC), acc = 0, topmV = mx;
  for (int v = HIST_MAX - 1; v >= 0; v--) {
    acc += hist[v];
    if (acc >= need) { topmV = v; break; }
  }
  const float k = 1.0f / 1000.0f / SHUNT_OHM;
  top  = topmV * k;
  vmax = mx * k;
  avg  = (sum / (float)N) * k;
}

// バッファの1秒波形から主周波数を求め、perRev(波数/回転)でRPMに換算。不成立なら -1
// removeMains: true なら 60Hz とその高調波(12次まで)を波形から引く (LED・電流用。電流のシャント電圧には60Hzが乗る)
float analyzeSpectrum(const uint16_t *buf, int perRev, bool removeMains) {
  // DC除去
  double s = 0;
  for (int n = 0; n < N; n++) s += buf[n];
  float mean = (float)(s / N);
  for (int n = 0; n < N; n++) x[n] = buf[n] - mean;

  if (removeMains) {
    // 60Hz高調波の係数を推定 (窓が整数周期なので直交)
    float a[HARM + 1], b[HARM + 1];
    for (int k = 1; k <= HARM; k++) {
      uint32_t step = (uint32_t)MAINS * k, idx = 0;
      double sc = 0, ss = 0;
      for (int n = 0; n < N; n++) {
        sc += x[n] * cosT(idx);
        ss += x[n] * sinTab[idx];
        idx += step; if (idx >= FS) idx -= FS;
      }
      a[k] = (float)(2.0 * sc / N);
      b[k] = (float)(2.0 * ss / N);
    }
    // 引き算
    for (int k = 1; k <= HARM; k++) {
      uint32_t step = (uint32_t)MAINS * k, idx = 0;
      for (int n = 0; n < N; n++) {
        x[n] -= a[k] * cosT(idx) + b[k] * sinTab[idx];
        idx += step; if (idx >= FS) idx -= FS;
      }
    }
  }
  // ハン窓
  for (int n = 0; n < N; n++) x[n] *= 0.5f - 0.5f * cosT(n);

  // DFT (1Hz刻み)
  double msum = 0; int pk = 0; float pkv = 0;
  for (int f = FMIN; f <= FMAX; f++) {
    uint32_t idx = 0;
    float re = 0, im = 0;
    for (int n = 0; n < N; n++) {
      re += x[n] * cosT(idx);
      im += x[n] * sinTab[idx];
      idx += f; if (idx >= FS) idx -= FS;
    }
    float m = sqrtf(re * re + im * im);
    mag[f - FMIN] = m;
    msum += m;
    if (m > pkv) { pkv = m; pk = f - FMIN; }
  }
  float avg = (float)(msum / (FMAX - FMIN + 1));
  if (pk <= 0 || pk >= FMAX - FMIN) return -1;
  if (avg <= 0 || pkv / avg < QUALITY_MIN) return -1;

  // 放物線補間
  float ma = mag[pk - 1], mb = mag[pk], mc = mag[pk + 1];
  float den = ma - 2 * mb + mc;
  float d = (den != 0) ? 0.5f * (ma - mc) / den : 0;
  float fpk = FMIN + pk + d;
  return fpk / perRev * 60.0f;
}

void setup() {
  Serial.begin(115200);
  analogSetPinAttenuation(PIN_VFAN, ADC_11db);
  analogSetPinAttenuation(PIN_TACH, ADC_11db);   // 3.3Vクランプ全域を読む
  analogSetPinAttenuation(PIN_LED,  ADC_2_5db);  // 不感帯が狭く、約1.4Vまで入る
  analogSetPinAttenuation(PIN_CUR,  ADC_6db);
  analogSetPinAttenuation(PIN_VG,   ADC_11db);
  for (int i = 0; i < FS; i++) sinTab[i] = sinf(2.0f * PI * i / FS);
}

void loop() {
  // ① ドレイン電圧 VD
  float vd = readDrainVoltage();

  // ②③④⑤ サンプリング 1.000秒 (GPIO25はVDの平均とは別に、脈動解析用に生値も毎サンプル読む)
  uint32_t vgSum = 0, vgMin = 0xFFFFFFFF, vgMax = 0;
  uint32_t t0 = micros();
  for (int i = 0; i < N; i++) {
    uint32_t target = t0 + (uint32_t)i * (1000000UL / FS);
    while ((int32_t)(micros() - target) < 0) { }
    tachBuf[i] = analogRead(PIN_TACH);
    rawBuf[i]  = analogRead(PIN_LED);
    curBuf[i]  = analogReadMilliVolts(PIN_CUR);
    drainBuf[i] = analogRead(PIN_VFAN);
    uint32_t g = analogReadMilliVolts(PIN_VG);
    vgSum += g;
    if (g < vgMin) vgMin = g;
    if (g > vgMax) vgMax = g;
  }
  uint32_t spanUs = micros() - t0;                               // サンプリング所要時間 (約1000000us なら正常)
  float vg   = (vgSum / (float)N) / 1000.0f * VG_DIV;           // Vg 1秒平均
  float vgpp = (vgMax - vgMin) / 1000.0f * VG_DIV;              // Vg p-p

  // 解析
  float tachRpm = analyzeTach();
  float ledRpm  = analyzeSpectrum(rawBuf, BLADES, true);
  float curRpm  = analyzeSpectrum(curBuf, CUR_RIPPLES_PER_REV, true);
  float drnRpm  = analyzeSpectrum(drainBuf, DRIPPLES_PER_REV, false);
  float itop, imax, iavg;
  analyzeCurrent(itop, imax, iavg);
  // FANの+端子と-端子の間の電圧
  float fanv = GATE_PCH ? (vd - iavg * SHUNT_OHM)   // Pch: FAN+ = ドレイン, FAN- = R1の上端
                        : (VCC_MEAS - vd);          // Nch: FAN+ = VCC,      FAN- = ドレイン

  // 出力
  Serial.printf("FAN_V= %.2fV  VD= %.2fV  ", fanv, vd);
  if (tachRpm >= 0) Serial.printf("TACH= %.0frpm  ", tachRpm); else Serial.print("TACH=---  ");
  if (ledRpm  >= 0) Serial.printf("LED= %.0frpm  ", ledRpm);   else Serial.print("LED=---  ");
  if (curRpm  >= 0) Serial.printf("IRPM= %.0frpm  ", curRpm);  else Serial.print("IRPM=---  ");
  if (drnRpm  >= 0) Serial.printf("DRPM= %.0frpm  ", drnRpm);  else Serial.print("DRPM=---  ");
  Serial.printf("IPK= %.3fA  IMAX= %.3fA  IAVG= %.3fA  ", itop, imax, iavg);
  float rth, tc;
  Serial.printf("VG= %.3fV  VGPP= %.3fV  ", vg, vgpp);
  if (calcRthTemp(vg, rth, tc)) Serial.printf("RTH= %.0fohm  T= %.1fC  ", rth, tc);
  else Serial.print("RTH=---  T=---  ");
  Serial.printf("TS= %luus\n", (unsigned long)spanUs);
}

DCファン駆動回路の実験:ローサイドNch MOSFET編

1. はじめに

サーミスタと小信号Nch MOSFET(2N7000)を用いた、アナログ式のDCファン温度制御回路を製作しました。サーミスタの抵抗変化をゲート電圧に変換し、MOSFETでモータ電流を連続的に調整する回路です。PWMによるデジタル制御ではなく、アナログ回路が自律的にファンを制御します。

今回は、この回路で駆動するファンの回転数・消費電流・サーミスタ温度をESP32で計測する方法を検討しました。

最大の問題は、FETをローサイドに配置したことによるタコ信号の検出不良です。

ファンのマイナス側にMOSFETを挿入すると、制御時にはファン内部のGND電位が回路全体のGNDから浮き上がります。このため、3線式ファンのタコ信号もESP32から見た基準電位がずれ、回転数を下げると正常に読み取れなくなりました。

そこで、タコ信号だけに頼らず、モータ電流の脈動、MOSFETのドレイン電圧の脈動、さらにLEDを利用した光学測定を試しました。

実験を通して分かったのは、測定対象やMOSFETの動作状態によって、利用できる信号が異なるということです。

  • 3線式ファン:タコ信号はローサイド駆動の影響を受け、低回転域では検出できなくなります。
  • 2線式ファン:タコ信号がないため、モータ由来の電流・電圧脈動を利用します。
  • 光学測定:ファンの回転そのものを検出できるため、比較基準として利用できます。
  • 温度測定:ゲート電圧からサーミスタ抵抗と温度を逆算します。

汎用的な計測器を開発することが目的ではありません。今回のアナログ式ファン制御回路で得られた現象と、実測に基づく測定方法の実験報告です。


2. 測定対象の回路と測定インターフェース

測定対象は、サーミスタと抵抗で生成したゲート電圧によって、MOSFETの電流を連続的に制御する回路です。

図1: LTspice回路図 - サーミスタ制御ファン駆動回路
図1: LTspice回路図 - サーミスタ制御ファン駆動回路

図2: 実験ベンチ・測定セットアップ
図2: 実験ベンチ・測定セットアップ

ESP32のADCを利用して、次の信号を測定します。

  • ドレイン電圧・ゲート電圧:抵抗分圧を介してADCに入力し、DMMの実測値に基づいて校正します。
  • タコ信号:ショットキーダイオードで3.3Vにクランプし、入力を保護します。
  • 光起電力:羽根の通過に伴う約0.14~1.3Vの信号をADCで測定します。
  • シャント電圧:電流波形を取得し、平均値・最大値・ピーク値を算出します。

サンプリング周波数は4kHz(250µs間隔)とし、4000点を1秒間の測定窓として扱います。これにより、60Hzの商用電源ノイズに対して、1秒窓内で60周期分を処理できます。

ローサイド駆動がタコ信号に与える影響

今回の回路では、MOSFETをファンのマイナス側に接続しています。

MOSFETが十分にオンしているときは、ドレイン電圧はGNDに近くなります。一方、MOSFETの電流制御によってドレイン電圧が上昇すると、ファン内部のGNDも回路のGNDから浮き上がります。

タコ出力がファン内部のGNDを基準に動作する場合、ESP32から見たタコ信号のLowレベルも上昇します。

実測では、低回転域でLowレベルが約2.1~2.3Vまで持ち上がりました。ESP32のGPIO入力ではLowと判定できない電圧になるため、割り込みによるパルス計数が破綻します。

これが、今回のローサイド駆動方式におけるタコ信号検出の最大の問題です。

なお、タコ信号のLowレベルが持ち上がる問題と、ファン内部回路の動作下限によってパルス自体が消える問題は別です。両者を区別して測定しました。


3. 回転数測定の4方式

回転数の測定には、タコ信号、光学検出、ソース電流脈動、ドレイン電圧脈動の4方式を試しました。

3.1 タコ信号による測定:ローサイド駆動の制約

3線式ファンのタコ出力はオープンコレクタ方式で、今回のファンでは2パルス/回転です。

一般的には、タコ出力のパルスをGPIO割り込みで数えれば回転数を求められます。しかし、今回の回路ではローサイド駆動によるGNDの浮き上がりが問題になりました。

ファン全開時はドレイン電圧がGND付近にあるため、タコ信号のLowレベルも十分に低く、パルスを読み取れます。

ところが、ファンの印加電圧を絞るとドレイン電圧が上昇し、タコ信号のLowレベルも約2.1~2.3Vまで上がります。ESP32のLow入力規定値を超えるため、GPIO割り込みでは正常なパルス計数ができません。

オシロ波形1: 低回転時のタコ信号波形
オシロ波形1: 低回転時のタコ信号波形

波形のLowレベルが持ち上がり、振幅が約1.2Vまで縮小しています。波形の歪みによって、多重エッジも発生します。

そこで、GPIO割り込みによる計数をやめ、ADCで取得した波形をソフトウェア処理する方式に変更しました。

処理内容は次のとおりです。

  1. 1秒間のADC波形を取得します。
  2. 波形のヒストグラムからHigh・Lowのレベルを推定します。
  3. 振幅の35%・65%をしきい値とするヒステリシス判定を行います。
  4. 粗い周期推定から求めた基本周期の60%をブランキング期間とし、多重エッジを除去します。

この処理によって、タコ信号が取得できる範囲では、光学測定と一致する回転数を得られました。

11:50:32.928 -> FAN_V= 2.77V  TACH= 1909rpm  TF= 1920rpm  TE= 64/159  LED= 1909rpm  IPK= 0.127A  IMAX= 0.238A  IAVG= 0.084A  VG= 2.368V  VGPP= 0.544V  RTH= 2614ohm  T= 58.6C  TS= 999971us
11:50:34.491 -> FAN_V= 2.90V  TACH= 1921rpm  TF= 1920rpm  TE= 64/152  LED= 1908rpm  IPK= 0.126A  IMAX= 0.288A  IAVG= 0.083A  VG= 2.369V  VGPP= 0.552V  RTH= 2610ohm  T= 58.6C  TS= 999971us
11:50:35.942 -> FAN_V= 3.50V  TACH=---  TF=---  TE=---  LED= 1906rpm  IPK= 0.131A  IMAX= 0.257A  IAVG= 0.084A  VG= 2.368V  VGPP= 0.638V  RTH= 2614ohm  T= 58.6C  TS= 999971us
11:50:37.392 -> FAN_V= 3.36V  TACH=---  TF=---  TE=---  LED= 1903rpm  IPK= 0.128A  IMAX= 0.263A  IAVG= 0.084A  VG= 2.368V  VGPP= 0.564V  RTH= 2613ohm  T= 58.6C  TS= 999976us
11:50:38.810 -> FAN_V= 3.48V  TACH=---  TF=---  TE=---  LED= 1904rpm  IPK= 0.133A  IMAX= 0.257A  IAVG= 0.085A  VG= 2.368V  VGPP= 0.528V  RTH= 2613ohm  T= 58.6C  TS= 999971us
11:50:40.257 -> FAN_V= 3.49V  TACH=---  TF=---  TE=---  LED= 1906rpm  IPK= 0.125A  IMAX= 0.264A  IAVG= 0.083A  VG= 2.363V  VGPP= 0.518V  RTH= 2623ohm  T= 58.5C  TS= 999975us
11:50:41.705 -> FAN_V= 3.32V  TACH=---  TF=---  TE=---  LED= 1906rpm  IPK= 0.130A  IMAX= 0.280A  IAVG= 0.084A  VG= 2.365V  VGPP= 0.480V  RTH= 2619ohm  T= 58.5C  TS= 999972us
11:50:43.164 -> FAN_V= 3.19V  TACH=---  TF=---  TE=---  LED= 1902rpm  IPK= 0.125A  IMAX= 0.267A  IAVG= 0.083A  VG= 2.368V  VGPP= 0.550V  RTH= 2614ohm  T= 58.6C  TS= 99997

TE=64/159は、未処理の159エッジから64エッジを採用したことを示します。

ただし、波形処理を工夫しても、入力信号のLowレベルがESP32の判定範囲から外れれば限界があります。実際に、約1900rpm付近では検出できなくなるケースが現れました。

さらに回転を下げると、タコ出力のパルス自体が消失しました。

オシロ波形2: 低回転域におけるタコ信号の消失
オシロ波形2: 低回転域1800rpmにおけるタコ信号の消失

12:55:53.332 -> FAN_V= 3.67V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1679rpm  IPK= 0.100A  IMAX= 0.151A  IAVG= 0.079A  VG= 2.431V  VGPP= 0.160V  RTH= 2496ohm  T= 59.9C  TS= 999970us
12:55:54.781 -> FAN_V= 3.60V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1674rpm  IPK= 0.102A  IMAX= 0.145A  IAVG= 0.080A  VG= 2.431V  VGPP= 0.154V  RTH= 2496ohm  T= 59.9C  TS= 999976us
12:55:56.217 -> FAN_V= 3.92V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1675rpm  IPK= 0.102A  IMAX= 0.145A  IAVG= 0.080A  VG= 2.431V  VGPP= 0.158V  RTH= 2497ohm  T= 59.9C  TS= 999971us
12:55:57.666 -> FAN_V= 3.91V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1682rpm  IPK= 0.100A  IMAX= 0.146A  IAVG= 0.079A  VG= 2.431V  VGPP= 0.154V  RTH= 2496ohm  T= 59.9C  TS= 999975us
12:55:59.149 -> FAN_V= 3.88V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1679rpm  IPK= 0.105A  IMAX= 0.150A  IAVG= 0.080A  VG= 2.431V  VGPP= 0.174V  RTH= 2497ohm  T= 59.9C  TS= 999971us
12:56:00.572 -> FAN_V= 4.07V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1679rpm  IPK= 0.102A  IMAX= 0.157A  IAVG= 0.079A  VG= 2.431V  VGPP= 0.156V  RTH= 2497ohm  T= 59.9C  TS= 999975us
12:56:02.033 -> FAN_V= 3.79V  TACH=---  TLV= 4064/4064  TF=---  TE=---  LED= 1674rpm  IPK= 0.102A  IMAX= 0.147A  IAVG= 0.079A  VG= 2.431V  VGPP= 0.160V  RTH= 2498ohm  T= 59.9C  TS= 999971us

このログでは、光学測定で約1674~1682rpmの回転を確認できる一方、タコ信号は取得できていません。TLV=4064/4064は、取得した波形の全サンプルが同じ判定側に入っている状態です。

したがって、タコ信号の検出限界には、次の2つの要因があります。

  1. ローサイド駆動による基準電位の浮き上がり:タコ信号が存在していても、ESP32から正常に読み取れなくなります。
  2. ファン内部回路の動作限界:回転数を下げると、タコパルス自体が出力されなくなる場合があります。

今回の回路では、タコ信号だけで低回転域まで計測することは困難でした。

3.2 LEDの光起電力による光学測定

次に、ファンの羽根を挟むように光源と3mm砲弾型緑色LEDを配置し、LEDの光起電力を直接ADCで測定しました。

LEDを受光素子として使用し、羽根が光路を通過する際の光量変化を電圧波形として取得します。ファンの電気回路やタコ信号に依存しないため、回転数の比較基準として利用できます。

LED受光治具とファン羽根の配置

図3: LED受光治具とファン羽根の配置

受光信号には、羽根の通過による約0.4~0.8Vppの波形が現れます。一方、室内照明による商用電源周波数60Hzのノイズも重畳します。

オシロ波形3: LED光起電力信号
オシロ波形3: LED光起電力信号に重畳する商用60Hzノイズ

このノイズを抑えるため、次の処理を実装しました。

  1. 1秒間の4000サンプルを取得します。
  2. 60Hzおよびその高調波について、正弦・余弦成分を推定して波形から減算します。
  3. ハン窓を適用してDFTを実行します。
  4. 1Hz刻みのスペクトルからピークを検出し、3点放物線補間で周波数を推定します。
  5. 羽根枚数で除算し、回転数に換算します。

羽根枚数は、今回の12Vファンが9枚、5Vファンが11枚です。

この方式では、ファン内部のタコ回路やMOSFETの電流制御状態に依存せず、羽根の回転を測定できます。今回の実験では、ほかの測定方式の精度を確認するための基準として使用しました。

3.3 ソース電流脈動による回転数推定

2線式ファンにはタコ出力がありません。そこで、ブラシレスDCモータの転流に伴う電流脈動を利用する方法を試しました。

ソース側のシャント抵抗(2Ω)で電流波形を取得し、スペクトル解析によって周期成分を抽出します。今回のファンでは、回転に対応する成分として4波/回転を利用しました。

高速域では電流脈動が観測でき、回転数を推定できます。しかし、回転数を下げると、シャント抵抗で観測できる脈動が小さくなり、周波数ピークを検出できなくなりました。

2400rpmでの電流リップル
オシロ波形4: 2400rpmでの電流リップル

2200rpmで電流リップルが平坦化
オシロ波形5: 2200rpmで電流リップルが平坦化

MOSFETが定電流動作に近い状態になると、ソース側シャント抵抗を流れる電流の変動が小さくなります。また、モータ端子間のコンデンサC1(47µF)が転流時の電流変動を局所的に供給するため、シャント抵抗側の波形では脈動が目立たなくなります。

この結果、約2200rpm以下では、ソース電流脈動を使った回転数推定が不安定になりました。

17:45:08.444 -> FAN_V= 1.58V  TACH= 2266rpm  LED= 2266rpm  IRPM= 2265rpm  DRPM= 2266rpm  IPK= 0.115A  IMAX= 0.142A  IAVG= 0.094A  VG= 2.601V  VGPP= 0.190V  RTH= 2212ohm  T= 63.3C  TS= 999965us
17:45:10.498 -> FAN_V= 1.75V  TACH= 2265rpm  LED= 2266rpm  IRPM= 2265rpm  DRPM= 2266rpm  IPK= 0.117A  IMAX= 0.164A  IAVG= 0.094A  VG= 2.601V  VGPP= 0.192V  RTH= 2211ohm  T= 63.3C  TS= 999970us
17:45:12.488 -> FAN_V= 1.32V  TACH= 2261rpm  LED= 2260rpm  IRPM= 2260rpm  DRPM= 2261rpm  IPK= 0.111A  IMAX= 0.167A  IAVG= 0.093A  VG= 2.596V  VGPP= 0.172V  RTH= 2219ohm  T= 63.2C  TS= 999965us
17:45:14.525 -> FAN_V= 1.40V  TACH= 2248rpm  LED= 2247rpm  IRPM= 2248rpm  DRPM= 2247rpm  IPK= 0.113A  IMAX= 0.164A  IAVG= 0.093A  VG= 2.596V  VGPP= 0.186V  RTH= 2219ohm  T= 63.2C  TS= 999965us
17:45:16.573 -> FAN_V= 1.37V  TACH= 2248rpm  LED= 2247rpm  IRPM=---  DRPM= 2248rpm  IPK= 0.113A  IMAX= 0.159A  IAVG= 0.093A  VG= 2.593V  VGPP= 0.202V  RTH= 2224ohm  T= 63.1C  TS= 999965us
17:45:18.554 -> FAN_V= 1.76V  TACH= 2241rpm  LED= 2240rpm  IRPM=---  DRPM= 2239rpm  IPK= 0.117A  IMAX= 0.163A  IAVG= 0.093A  VG= 2.591V  VGPP= 0.158V  RTH= 2228ohm  T= 63.1C  TS= 999965us
17:45:20.583 -> FAN_V= 1.63V  TACH= 2240rpm  LED= 2240rpm  IRPM= 2237rpm  DRPM= 2239rpm  IPK= 0.106A  IMAX= 0.154A  IAVG= 0.093A  VG= 2.590V  VGPP= 0.164V  RTH= 2229ohm  T= 63.1C  TS= 999970us
17:45:22.639 -> FAN_V= 1.89V  TACH= 2235rpm  LED= 2236rpm  IRPM=---  DRPM= 2236rpm  IPK= 0.114A  IMAX= 0.157A  IAVG= 0.093A  VG= 2.589V  VGPP= 0.200V  RTH= 2230ohm  T= 63.1C  TS= 999970us
17:45:24.619 -> FAN_V= 1.71V  TACH= 2236rpm  LED= 2237rpm  IRPM= 2236rpm  DRPM= 2236rpm  IPK= 0.113A  IMAX= 0.160A  IAVG= 0.093A  VG= 2.590V  VGPP= 0.216V  RTH= 2229ohm  T= 63.1C  TS= 999966us
17:45:26.673 -> FAN_V= 1.43V  TACH= 2240rpm  LED= 2240rpm  IRPM=---  DRPM= 2239rpm  IPK= 0.112A  IMAX= 0.142A  IAVG= 0.093A  VG= 2.590V  VGPP= 0.180V  RTH= 2229ohm  T= 63.1C  TS= 999965us
17:45:28.669 -> FAN_V= 1.79V  TACH= 2232rpm  LED= 2233rpm  IRPM=---  DRPM= 2233rpm  IPK= 0.115A  IMAX= 0.153A  IAVG= 0.093A  VG= 2.589V  V

このログは、電流脈動による測定値(IRPM)が一部の動作点で得られなくなることを示しています。なお、このログ自体は約2230~2270rpm付近の測定であり、2200rpm以下の限界を直接示すデータではありません。限界の判定は、電流波形と周波数スペクトルの観測結果に基づきます。

3.4 ドレイン電圧脈動による回転数推定

ソース側の電流脈動が検出できなくなっても、モータ端子間のコンデンサに転流電流が流れるなら、その影響はドレイン電圧に現れる可能性があります。

そこで、MOSFETのドレイン電圧をADCで取得し、スペクトル解析によって回転数を推定しました。

オシロ波形6: 低回転域におけるドレイン電圧脈動波形
オシロ波形6: 低回転域におけるドレイン電圧脈動波形

オシロスコープで確認すると、ソース電流の脈動が小さくなった領域でも、ドレイン電圧には周期的な変動が残っていました。

この波形から4波/回転に対応する周波数成分を抽出すると、ソース電流脈動では検出できなかった回転数を推定できました。

20:05:03.147 -> FAN_V= 3.98V  TACH=---  LED= 1567rpm  IRPM=---  DRPM= 1568rpm  IPK= 0.087A  IMAX= 0.127A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.162V  RTH= 2644ohm  T= 58.3C  TS= 999965us
20:05:05.070 -> FAN_V= 4.04V  TACH=---  LED= 1564rpm  IRPM=---  DRPM= 1562rpm  IPK= 0.082A  IMAX= 0.127A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.158V  RTH= 2644ohm  T= 58.3C  TS= 999964us
20:05:06.969 -> FAN_V= 4.10V  TACH=---  LED= 1565rpm  IRPM=---  DRPM= 1566rpm  IPK= 0.085A  IMAX= 0.119A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.154V  RTH= 2643ohm  T= 58.3C  TS= 999970us
20:05:08.883 -> FAN_V= 4.02V  TACH=---  LED= 1563rpm  IRPM=---  DRPM= 1563rpm  IPK= 0.085A  IMAX= 0.132A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.168V  RTH= 2643ohm  T= 58.3C  TS= 999965us
20:05:10.822 -> FAN_V= 4.03V  TACH=---  LED= 1562rpm  IRPM=---  DRPM= 1561rpm  IPK= 0.091A  IMAX= 0.117A  IAVG= 0.072A  VG= 2.351V  VGPP= 0.180V  RTH= 2645ohm  T= 58.3C  TS= 999965us
20:05:12.701 -> FAN_V= 4.00V  TACH=---  LED= 1564rpm  IRPM=---  DRPM= 1563rpm  IPK= 0.088A  IMAX= 0.128A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.156V  RTH= 2643ohm  T= 58.3C  TS= 999965us
20:05:14.642 -> FAN_V= 4.05V  TACH=---  LED= 1565rpm  IRPM=---  DRPM= 1564rpm  IPK= 0.086A  IMAX= 0.117A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.174V  RTH= 2643ohm  T= 58.3C  TS= 999965us
20:05:16.534 -> FAN_V= 4.08V  TACH=---  LED= 1564rpm  IRPM=---  DRPM= 1563rpm  IPK= 0.087A  IMAX= 0.119A  IAVG= 0.072A  VG= 2.352V  VGPP= 0.150V  RTH= 2643ohm  T= 58.3C  TS= 999969us

このログでは、タコ信号(TACH)とソース電流脈動(IRPM)が取得できない一方、ドレイン電圧脈動(DRPM)は約1560rpmを示し、LEDによる光学測定とほぼ一致しています。

元の説明にあった「LED光学基準2101rpm」は、このログの数値とは一致しないため、ここでは実測ログに合わせました。

ただし、ドレイン電圧脈動も万能ではありません。さらに低回転になると、4波/回転以外の成分が優勢になり、周波数判定を誤る場合があります。


4. 回転数測定方式の比較

4.1 12Vファン:3線式・9枚羽根・定格12V 0.36A

方式 検出対象 有効回転数範囲(実測) 主な制約
タコ信号 GPIO4 約1900~2470rpm ローサイド駆動によるLowレベルの上昇。約1680rpmではパルス自体も消失
LED光起電力 GPIO34 約1360~2470rpm 商用60Hzノイズの除去が必要

タコ信号は高速~中速域で利用できますが、ローサイド駆動ではGNDの浮き上がりが検出の障害になります。低回転域を含めて測定するには、光学検出など別の手段が必要です。

4.2 5Vファン:2線式・11枚羽根・定格5V 0.18A

方式 検出対象 有効回転数範囲(実測) 主な制約
LED光起電力 GPIO34 約1400~3600rpm 光学系の設置と60Hzノイズ対策が必要
ソース電流脈動 GPIO35 約3200~3600rpm 低速域ではシャント電流の脈動が小さくなり、ピークを検出できない
ドレイン電圧脈動 GPIO25 約1400~2100rpm 約1400rpm以下では高調波成分の影響で誤判定が発生

※有効範囲は今回の実験で確認した範囲です。ファンの型式、回路定数、測定条件によって変わります。

2線式ファンでは、外部センサを追加しなくてもドレイン電圧の脈動から回転数を推定できました。ただし、ソース電流脈動とドレイン電圧脈動では検出可能な回転数範囲が異なります。


5. 電流測定とサンプリング帯域の限界

電流測定には、ローサイドシャント抵抗(\(R_1=2.0\Omega\))を使用しました。シャント抵抗の両端電圧からモータ電流を算出します。

ブラシレスDCモータの電流は一定ではなく、転流に伴うスパイクやリップルを含みます。そこで、次の3つの指標を算出しました。

  1. 平均電流(IAVG):4000点の単純平均です。測定窓内の平均的な電流を表します。
  2. 最大瞬時電流(IMAX):測定窓内で観測された最大値です。単発ノイズの影響を受けます。
  3. ピーク電流(IPK):電流波形の上端レベルを安定して評価するための指標です。

IPKは、0~2500mVを1mV刻みで集計したヒストグラムから、上位2%に相当する値を抽出して電流に換算します。単発のスパイクに左右されにくい一方、真の瞬時最大値を示すものではありません。

オシロ電流
オシロ波形:シャント抵抗両端の電流波形とTop値拡大

オシロスコープでは、シャント抵抗両端の電圧が656mVとなるTop値を確認しました。シャント抵抗が2Ωの場合、これは次の電流に相当します。

\[ I=\frac{0.656}{2.0}=0.328\ \mathrm{A} \]

実測値を比較します。

測定器・項目 Top・ピーク値 平均値
オシロスコープ 0.328A(656mV) ―
DMM ― 0.075A
ESP32 0.132A(IPK) 0.099A(IAVG)

ESP32のIPKはオシロスコープのTop値より小さくなっています。これは、4kHzのサンプリングでは250µsごとの離散的な測定となり、短時間の転流スパイクを捉えられないためです。

したがって、IPKはオシロスコープで観測される瞬時ピーク値とは異なります。また、ESP32のIAVGがDMMの平均値より高くなった原因として、ADCのゼロ点付近の誤差や入力回路のオフセットが考えられます。この点は、実測による補正が必要です。


6. ゲート電圧からサーミスタ抵抗・温度を逆算する

この回路では、ESP32が温度を制御しているわけではありません。サーミスタと抵抗で構成した分圧回路がゲート電圧を生成し、MOSFETを自律的に制御します。

そこで、ESP32ではゲート電圧 (V_g) を測定し、回路方程式からサーミスタ抵抗 (R_{th}) と温度 (T) を逆算します。

回路の関係式を次のように表します。

\[ V_g=V_{cc}\cdot \frac{R_V'}{R_V'+R_s+R_{th}} \]

これを (R_{th}) について解くと、

\[ R_{th}=R_V'\cdot \frac{V_{cc}-V_g}{V_g}-R_s \]

となります。

ただし、ゲート電圧を測定するために接続した分圧抵抗も、元の制御回路に負荷として作用します。そのため、この影響を補正します。

分圧抵抗は上側4635Ω、下側4633Ωで、直列合計は9268Ωです。この測定回路が制御回路の下側抵抗 (R_V) に並列接続されるため、合成抵抗を次式で求めます。

\[ R_V'=\frac{R_V\cdot9268}{R_V+9268} \]

得られたサーミスタ抵抗をSteinhart–Hart方程式に代入し、温度を計算します。

\\[ \frac{1}{T} =A+B\ln(R\_{th})+C\left(\ln(R\_{th})\right)^3 \\]

係数は次のとおりです。

\[ \begin{aligned} A&=1.129148\times10^{-3}\ B&=2.34125\times10^{-4}\ C&=8.76741\times10^{-8} \end{aligned} \]

ここで (T) は絶対温度[K]です。摂氏温度は (T-273.15) で求めます。

[シリアルログ4: ゲート電圧からのサーミスタ抵抗・温度の逆算出力]
09:37:24.805 -> FAN_V= 18.09V  TACH= 4202rpm  LED= 2101rpm  IRPM=---  DRPM= 2101rpm  IPK= 0.105A  IMAX= 0.173A  IAVG= 0.084A  VG= 2.532V  VGPP= 0.198V  RTH= 2208ohm  T= 63.4C  TS= 999965us
09:37:26.781 -> FAN_V= 18.26V  TACH= 4202rpm  LED= 2101rpm  IRPM=---  DRPM= 2101rpm  IPK= 0.109A  IMAX= 0.165A  IAVG= 0.085A  VG= 2.533V  VGPP= 0.198V  RTH= 2207ohm  T= 63.4C  TS= 999965us
09:37:28.804 -> FAN_V= 17.83V  TACH= 4203rpm  LED= 2101rpm  IRPM=---  DRPM= 2101rpm  IPK= 0.107A  IMAX= 0.171A  IAVG= 0.084A  VG= 2.533V  VGPP= 0.188V  RTH= 2206ohm  T= 63.4C  TS= 999965us
09:37:30.854 -> FAN_V= 17.54V  TACH= 4202rpm  LED= 2101rpm  IRPM=---  DRPM= 2101rpm  IPK= 0.112A  IMAX= 0.166A  IAVG= 0.085A  VG= 2.533V  VGPP= 0.190V  RTH= 2206ohm  T= 63.4C  TS= 999969us
09:37:32.852 -> FAN_V= 18.08V  TACH= 4209rpm  LED= 2104rpm  IRPM=---  DRPM= 2103rpm  IPK= 0.104A  IMAX= 0.142A  IAVG= 0.084A  VG= 2.533V  VGPP= 0.198V  RTH= 2206ohm  T= 63.4C  TS= 999969us

この例では、ゲート電圧2.533Vからサーミスタ抵抗2206Ωを逆算し、温度63.4℃を算出しています。

この方法では、追加の温度センサを設けずに、アナログ制御回路の動作点をモニタできます。ただし、計算精度は抵抗値、電源電圧、ゲート電圧の校正精度、およびサーミスタの特性係数に依存します。


7. まとめ

今回の実験では、ローサイドMOSFETによるアナログ式ファン制御回路について、回転数・電流・温度をESP32で測定する方法を検討しました。

最も重要な知見は、ローサイド駆動によってタコ信号の基準電位が浮き上がることです。

MOSFETをファンのマイナス側に配置したため、制御時にはファン内部のGNDが回路のGNDから浮き上がります。その結果、タコ信号のLowレベルが約2.1~2.3Vまで上昇し、ESP32のGPIOでは正常にLowとして認識できなくなりました。ソフトウェアでしきい値やエッジ判定を工夫しても、この電位差そのものは解消できません。

さらに低回転域では、ファン内部回路の動作下限によってタコパルス自体が消失する現象も確認しました。

この制約に対して、今回の実験では次の方法が有効でした。

  • 3線式ファン:タコ信号が取得できる範囲ではタコ信号を使用し、低回転域では光学測定を利用します。
  • 2線式ファン:高速域ではソース電流脈動、中低速域ではドレイン電圧脈動から回転数を推定します。
  • 光学測定:ファンの回転を直接検出する基準として使用します。
  • 温度測定:ゲート電圧と回路方程式からサーミスタ抵抗・温度を逆算します。

ローサイド駆動では、ファン内部の信号を回路GND基準で測定すること自体に制約が生じます。一方、ドレイン電圧にはモータの転流に伴う脈動が残るため、タコ信号のない2線式ファンでも、一定の回転数範囲で回転数を推定できました。

今回の結果は、ファンの回転数を単一の信号から測定するのではなく、回路構成と動作状態に応じて、観測可能な信号を使い分けることが重要であることを示しています。

keyboard_double_arrow_down

keyboard_double_arrow_down

Claude Logo