2025,江端さんの忘備録

e-Learnigで私の勤務している会社の育児休暇システムについて受講しました。
I took an e-learning course on the company's parental leave system.

なるほど、行き届いたシステムです。さすがは社会インフラを提供する会社だと思いました。
I thought it was indeed a well-designed and thorough system—just what one would expect from a company that provides social infrastructure.

しかし、『運用がなぁ』と思いました。
However, I found myself thinking, “But the operation leaves much to be desired.”

育児休暇に対して、(1)うんざりする程の申請手続と、(2)関連する人間への説明を、自力で行わなければ、このシステムが動き出さない、ということです。
When it comes to parental leave, the system does not even begin to function unless one personally carries out (1) an exhausting amount of application procedures and (2) explanations to all related parties.

まあ、これが必要というのは分かるのですが、これが、「育児休暇取得を"妨害"する機能」であると考えられないのかな、と思います。
I understand that some of this may be necessary, but I cannot help but wonder whether it effectively serves as a mechanism that “obstructs” the taking of parental leave.

-----

欧州には、死亡後の臓器移植率が恐しく高い国(スペイン)があります(ちなみに、日本では数%程度です)。
In Europe, there is a country—Spain—where the rate of post-mortem organ donation is astonishingly high (by comparison, in Japan it is only a few percent).

この理由はとっても簡単な仕組みで成立しています。
A straightforward mechanism can explain this.

デフォルトが「臓器提供をする」であって、「臓器提供を希望しない人」がチェックを付ける、という仕組みになっているからです(もちろん、その理由を付記する必要はありません)。
The default setting is “organ donation is provided,” and only those who do not wish to donate are required to check a box (with no need to provide any justification).

つまり、デフォルト設定が大勢が決めるです ーー これは行動経済学で必ず登場する 「デフォルト効果(現状維持バイアス)」 というものです。
In other words, the default setting ends up determining what most people choose—this is what behavioral economics refers to as the “default effect (status quo bias).”

つまり、自分またはパートナーの妊娠・出産を決定したら、その予定日時のみ入れれば、あとはシステムが「最大値の育児休暇取得期間」が設定されて、後は何もしなくてもシステムが片づけてくれる(「関係者への連絡も自動的に送付される」も含む)くらいのシステムはできはずです。
That is to say, once you or your partner decides on pregnancy or childbirth, you should only need to enter the expected date, and the system would then set the “maximum parental leave period,” handling everything else automatically—including sending notifications to all relevant parties.

できますよ。"やれ"と言われれば、私がITシステムを作りましょう。
It can be done. If someone told me to “go ahead and do it,” I would build such an IT system myself.

で、その内容を「減縮や短縮を希望する」場合に限って、ユーザがシステムに入力を行う ーー この程度まで簡易化しないと、育児休暇という仕組み(法律とか実施実績とか)は整ったとしても、育児休暇の究極の目的である出生率の増加には繋がらないだろうと思うんですよ。
Only when a user wishes to reduce or shorten that default period should they need to input anything into the system—unless it is simplified to this extent, I do not believe that parental leave, no matter how well established legally or institutionally, will ever lead to its ultimate goal: an increase in the birthrate.

つまるところ、組織として必要なのは「デフォルトという強制力」だと思うんですよね。
In the end, what organizations truly need is the coercive power of defaults.

-----

ただなぁ、このようなデフォルトが「逆強制力」として働くと、さらに育児休暇を取りにくくなり、かつ、出生率を下げる結果にもなりかねません。
That said, if such defaults begin to function as a form of “reverse coercion,” they could make parental leave even more complicated to take and potentially result in an even lower birthrate.

つまるところ、デフォルトと 自由意思(裁量) の中間あたりを上手く調整するところが、組織の(特にHR)としての「頭の良さ」だと思うんですよね
Ultimately, I believe that skillfully balancing between defaults and free will (discretion) is what demonstrates an organization’s intelligence—especially in HR.

結局のところ、制度を作った時点で満足する組織は三流で、その制度を「使わされる側の負担」まで含めて設計できるかどうかが、組織の「有能」と「無能」分ける分水嶺なのだと思います。
In the end, organizations that are satisfied merely with having created a system are third-rate; whether they can design that system while accounting for the burden placed on those who must use it is the true dividing line between organizational “competence” and “incompetence.”

―― 育児や介護を優先する代わりとして、『こんな国に、二度と生まれてきたくない』と憎悪する人間を一定数生み出す国になる覚悟

未分類

連載コラムに掲載する絵を、ChatGPTに作って貰ったものを使うのに問題はありますか?

結論から言えば、原則として問題ありません。ただし、いくつか注意点があります。連載コラムという「公開・継続利用」を前提にすると、そこは押さえておいた方が安全です。


1. 著作権・利用権の観点(最重要)

ChatGPT(画像生成機能)で作成したイラストについては、

  • 商用利用を含めて使用可能

  • 連載コラムへの掲載も可能

  • 追加の利用料や許諾は不要

という扱いになっています。
つまり、コラムに載せること自体は問題ありません

また、

  • あなたが「指示して生成させた画像」を

  • あなた自身のコンテンツ(コラム)に使う

という構図なので、一般的な出版社・Webメディアの基準でも問題になりにくいです。


2. ただし注意すべき点(実務的に重要)

(1) 「完全な独占著作権」ではない

ChatGPTが生成した画像は、

  • あなたが自由に使える

  • しかし「世界で唯一あなた専用の著作物」とは言い切れない

という性質があります。

つまり、

  • 同じような指示をすれば、似た絵が他でも生成される可能性はゼロではありません。

ただし、
連載コラムの挿絵用途で「絵そのものを資産として独占管理する」必要がない限り、実害はほぼありません


(2) 実在作家・既存作品の「模倣」に見えないこと

安全のために重要なのは、

  • 「○○風」「△△の画風そのまま」

  • 特定の作家名・キャラクターを強く想起させる表現

を避けることです。

あなたがこれまで依頼しているような、

  • 抽象的

  • 研究・思考・海・数式・エージェントといった概念表現

  • オリジナル構図

であれば、問題になる可能性は極めて低いです。


(3) メディア・出版社の内規

ごくまれに、

  • 「生成AI画像は不可」

  • 「使用時は明記が必要」

という媒体ルールがあります。

ただしこれは法的問題ではなく編集方針です。
EE Times Japan や個人ブログ、研究者コラムの文脈では、現状ほぼ問題になりません。

不安な場合は、

  • 「挿絵は生成AIを用いて作成」
    と脚注やクレジットに一言入れる、という対応もあります(必須ではありません)。


3. 江端さんの連載との相性という観点

むしろ率直に言えば、

  • MAS

  • 仮想世界

  • 抽象化

  • 思考実験

  • シミュレーション

  • 現実とモデルの二重性

を語る連載において、

生成AIによるイラストは、内容と思想的に非常に整合的です。

「人間をモデル化する話」を
「人間がAIに描かせたイラスト」で表現する、という構造自体が、
メタ的で、江端さんの文章と相性が良い


まとめ(実務的結論)

  • ✔ 連載コラムへの掲載は問題なし

  • ✔ 商用・公開利用も問題なし

  • ✔ 著作権トラブルの可能性は低い

  • △ 独占著作権が必要な用途には不向き

  • △ 媒体ルールだけは一応確認

総合すると、
**「普通に使ってよい。むしろ自然」**という判断になります。

もし次に進めるなら、

  • 「この絵は連載全体の象徴にできるか」

  • 「回を跨いで使い回す/変奏させる」

といった編集的な使い方も、かなり面白くなりそうです。

未分類

目的
GPU付きPCを購入したが、

  • 本当にGPU計算が動くのか

  • Go言語から使えるのか

  • CPUと比べてどの部分が速く/遅くなるのか

を、誰でも再現できる形で確認する。

失敗した試行や遠回りはすべて排除し、
**「この順でやれば必ず動く」**内容のみを記載します。


0. 前提条件(必須)

ハードウェア

  • NVIDIA GPU(例:RTX 4060 Laptop GPU)

    • ※ AMD / Intel GPU でも理論上可だが、本手順は NVIDIA 前提

OS

  • Windows 10 / 11(64bit)


1. NVIDIA ドライバの確認(最重要)

1.1 NVIDIA ドライバが入っているか

PowerShell で確認:

nvidia-smi
  • GPU名とドライバ情報が表示されればOK

  • 表示されなければ、NVIDIA公式サイトから最新ドライバをインストール


2. Go 言語のインストール

2.1 Go をインストール

  • https://go.dev/dl/

  • Windows用インストーラ(msi)を実行

  • デフォルト設定でOK

確認:

go version

例:

go version go1.22.x windows/amd64

3. MSYS2 のインストール(OpenCL用)

3.1 MSYS2 をインストール

3.2 UCRT64 シェルを使用する(重要)

スタートメニューから:

MSYS2 UCRT64

を起動すること。


4. OpenCL 開発環境の導入(UCRT64)

4.1 パッケージ更新

pacman -Syu

(再起動を求められたら UCRT64 を再起動)

4.2 OpenCL 関連パッケージをインストール

pacman -S --needed \
  mingw-w64-ucrt-x86_64-opencl-headers \
  mingw-w64-ucrt-x86_64-opencl-icd

4.3 OpenCL ライブラリの存在確認(重要)

ls /ucrt64/lib | grep OpenCL

期待される出力:

libOpenCL.dll.a

これが 見えなければ先に進まない


5. Go から OpenCL を使う準備

5.1 作業ディレクトリ作成

PowerShell(任意の場所):

mkdir go_test
cd go_test
go mod init go_test

5.2 OpenCL Go バインディングを取得

go get github.com/jgillich/go-opencl/cl

6. OpenCL 1.2 指定(最重要ポイント)

問題点

  • OpenCL Headers はデフォルトで OpenCL 3.0

  • go-openclOpenCL 1.2 前提

  • 指定しないとビルドエラーになる

6.1 環境変数を設定(永続)

PowerShell で一度だけ実行:

setx CGO_CFLAGS "-DCL_TARGET_OPENCL_VERSION=120 -DCL_UNORM_INT24=0x10DF -DCL_DEPTH_STENCIL=0x10BE"

重要

  • 実行後 VS Code / PowerShell を完全に再起動

確認:

echo $env:CGO_CFLAGS

表示されればOK。


7. VS Code の準備

7.1 VS Code インストール

7.2 拡張機能

  • Go(公式拡張)


8. 動作確認用プログラム(全文)

以下を main.go として保存

package main

import (
	"encoding/binary"
	"fmt"
	"math"
	"math/rand"
	"time"
	"unsafe"

	"github.com/jgillich/go-opencl/cl"
)

const kernelSrc = `
__kernel void vadd(__global float* a, __global float* b, const int n) {
	int i = get_global_id(0);
	if (i < n) {
		a[i] = a[i] + b[i];
	}
}
`

func cpuVadd(a, b []float32) {
	for i := range a {
		a[i] += b[i]
	}
}

func f32ToBytes(xs []float32) []byte {
	b := make([]byte, 4*len(xs))
	for i, v := range xs {
		binary.LittleEndian.PutUint32(b[i*4:], math.Float32bits(v))
	}
	return b
}

func bytesToF32(b []byte) []float32 {
	n := len(b) / 4
	xs := make([]float32, n)
	for i := 0; i < n; i++ {
		u := binary.LittleEndian.Uint32(b[i*4:])
		xs[i] = math.Float32frombits(u)
	}
	return xs
}

func main() {
	const N = 5_000_000
	const CPU_REPEAT = 5
	const GPU_REPEAT = 10

	a := make([]float32, N)
	b := make([]float32, N)

	rng := rand.New(rand.NewSource(1))
	for i := 0; i < N; i++ {
		a[i] = rng.Float32()
		b[i] = rng.Float32()
	}

	// CPU
	aCPU := make([]float32, N)
	copy(aCPU, a)
	cpuVadd(aCPU, b)

	t0 := time.Now()
	for i := 0; i < CPU_REPEAT; i++ {
		cpuVadd(aCPU, b)
	}
	fmt.Println("CPU avg time:", time.Since(t0)/CPU_REPEAT)

	// OpenCL
	platforms, _ := cl.GetPlatforms()
	devs, _ := platforms[0].GetDevices(cl.DeviceTypeAll)
	dev := devs[0]
	fmt.Println("Using device:", dev.Name())

	ctx, _ := cl.CreateContext([]*cl.Device{dev})
	queue, _ := ctx.CreateCommandQueue(dev, 0)

	aBytes := f32ToBytes(a)
	bBytes := f32ToBytes(b)

	tCopy0 := time.Now()
	bufA, _ := ctx.CreateBuffer(cl.MemReadWrite|cl.MemCopyHostPtr, aBytes)
	bufB, _ := ctx.CreateBuffer(cl.MemReadOnly|cl.MemCopyHostPtr, bBytes)
	fmt.Println("GPU copy host->device:", time.Since(tCopy0))

	prog, _ := ctx.CreateProgramWithSource([]string{kernelSrc})
	prog.BuildProgram([]*cl.Device{dev}, "")
	kernel, _ := prog.CreateKernel("vadd")
	kernel.SetArgs(bufA, bufB, int32(N))

	queue.EnqueueNDRangeKernel(kernel, nil, []int{N}, nil, nil)
	queue.Finish()

	t1 := time.Now()
	for i := 0; i < GPU_REPEAT; i++ {
		queue.EnqueueNDRangeKernel(kernel, nil, []int{N}, nil, nil)
	}
	queue.Finish()
	fmt.Println("GPU kernel avg time:", time.Since(t1)/GPU_REPEAT)

	outBytes := make([]byte, len(aBytes))
	queue.EnqueueReadBuffer(
		bufA,
		true,
		0,
		len(outBytes),
		unsafe.Pointer(&outBytes[0]),
		nil,
	)

	out := bytesToF32(outBytes)
	fmt.Println("check:", out[0], aCPU[0])
}

9. 実行

go clean -cache
go run .

10. 期待される出力例

CPU avg time: 2.8ms
Using device: NVIDIA GeForce RTX 4060 Laptop GPU
GPU kernel avg time: 0.5ms
GPU copy host->device: 10ms
check: (一致しない値)

11. 結果の正しい理解

  • GPUは 計算だけなら圧倒的に速い

  • 転送(CPU↔GPU)が非常に重い

  • 転送込みでは CPU の方が速くなるのは正常

  • GPUは

    • データを載せたまま

    • 大量・反復・重い計算
      で真価を発揮する


12. 本検証で得られた結論

  • Go言語からGPU(OpenCL)は 実用的に利用可能

  • GPU性能評価では

    • 計算

    • 転送
      を必ず分けて考える必要がある

  • 本コードは GPU評価の 最小再現テンプレートとして有用

以上

未分類

以下に、本日作業した内容を「そのまま再現できるメモ」として、必要なコマンド/修正したファイル全文(index.html / server.py)/動作確認手順まで、全部まとめて記載します。


1. 目的

  • index.htmliframe 4枚(sample2 / rides / bus_map / trips_map) を同時表示している

  • ユーザが 任意の iframe を選び、ボタン押下で「ブラウザに表示されている内容と一致する PNG」 をローカル保存する


2. 問題と原因

2.1 保存画像と表示内容が一致しない

  • /shot で Playwright(別ブラウザセッション)を使ってHTMLを描画してPNG化していた

  • そのPlaywright側には ブラウザの cookie(date_start/date_end) が無い
    → CSV生成期間がズレる → 表示と保存が不一致

2.2 bus_map / trips_map が「過去のデータ」っぽくなる

  • cookieが更新されても、iframeが「同じURLのまま」再利用され、再描画が起きない/キャッシュが残るケース
    → bus_map/trips_map の表示が更新されず古い状態に見える


3. 対策(実装方針)

3.1 表示と保存の“期間”を一致させる

  • /shot に来たリクエストの cookie(date_start/date_end)を Playwright に注入

  • Playwright が取得するCSVもブラウザと同じ期間になる

3.2 iframe表示を常に最新化(強制リロード)

  • index.html/range.json から start/end を取得

  • iframe の src?v=<start>_<end>_<timestamp> 付きURLに置き換える
    → cookie変更後でも必ず再読み込みが走る

3.3 保存対象は「今iframeが表示しているURL(クエリ込み)」

  • 保存時、固定パスではなく iframe.src を採用し、pathname + search/shot に渡す
    → 「見えているもの」=「保存されるもの」を保証


4. 必要コマンド(サーバ側)

4.1 Playwright の導入

pip install playwright
playwright install chromium ※ Linux環境でブラウザ依存ライブラリ不足が出る場合(最小例、Ubuntu系)
sudo apt-get update
sudo apt-get install -y \
  libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
  libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \
  libxrandr2 libgbm1 libasound2 libpangocairo-1.0-0 libpango-1.0-0 \
  libgtk-3-0

4.2 起動

python3 server.py

(ポートを変えるなら)

SAMPLE2_PORT=18080 python3 server.py

5. 修正後のコード(全文)

5.1 index.html(全文)

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>sample2 + rides + bus_map + trips_map</title>
<style>
body{font-family:sans-serif;margin:16px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.panel{margin-top:16px;border:1px solid #ccc;border-radius:8px;overflow:hidden}
.panel h2{margin:0;padding:8px;background:#f5f5f5;display:flex;align-items:center;justify-content:space-between;gap:12px}
.panel h2 .right{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
iframe{width:100%;border:0}
.h1{height:640px}
.h2{height:640px}
.h3{height:720px}
.h4{height:720px}
a.btn{padding:6px 10px;border:1px solid #888;text-decoration:none;color:#000;border-radius:8px;background:#fff}
button.btn{padding:6px 10px;border:1px solid #888;border-radius:8px;background:#fff;cursor:pointer}
select{padding:6px 8px}
.small{font-size:12px;color:#333}
</style>
</head>
<body>

<h1>日時指定(4表示)</h1>

<form method="POST" action="/set_range" class="row">
  <label>開始日 <input id="start" type="date" name="start" required></label>
  <label>終了日 <input id="end" type="date" name="end" required></label>
  <button type="submit">OK(表示)</button>

  <a class="btn" href="/sample2.csv?download=1">CSV(sample2)</a>
  <a class="btn" href="/ride_summary.csv?download=1">CSV(rides)</a>
  <a class="btn" href="/bus_stops.csv?download=1">CSV(bus_stops)</a>
  <a class="btn" href="/trips.csv?download=1">CSV(trips)</a>
</form>

<div class="row" style="margin-top:10px">
  <span class="small">保存対象</span>
  <select id="shotTarget">
    <option value="f1">sample2</option>
    <option value="f2">rides</option>
    <option value="f3">bus_map</option>
    <option value="f4">trips_map</option>
  </select>
  <button type="button" class="btn" onclick="saveSelected()">選択した画面をPNG保存</button>
  <span id="shotMsg" class="small"></span>
</div>

<div class="panel">
  <h2>
    <span>sample2</span>
    <span class="right">
      <button type="button" class="btn" onclick="saveFrame('f1','sample2')">PNG保存</button>
    </span>
  </h2>
  <iframe id="f1" class="h1" src="/sample2.html"></iframe>
</div>

<div class="panel">
  <h2>
    <span>rides</span>
    <span class="right">
      <button type="button" class="btn" onclick="saveFrame('f2','rides')">PNG保存</button>
    </span>
  </h2>
  <iframe id="f2" class="h2" src="/rides.html"></iframe>
</div>

<div class="panel">
  <h2>
    <span>bus_map</span>
    <span class="right">
      <button type="button" class="btn" onclick="saveFrame('f3','bus_map')">PNG保存</button>
    </span>
  </h2>
  <iframe id="f3" class="h3" src="/bus_map.html"></iframe>
</div>

<div class="panel">
  <h2>
    <span>trips_map</span>
    <span class="right">
      <button type="button" class="btn" onclick="saveFrame('f4','trips_map')">PNG保存</button>
    </span>
  </h2>
  <iframe id="f4" class="h4" src="/trips_map.html"></iframe>
</div>

<script>
function pad(n){ return String(n).padStart(2,'0'); }
function nowStr(){
  const d = new Date();
  return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}

// cookieの開始/終了をサーバから取得してフォームに反映し、iframeを強制リロード
async function syncRangeAndReloadFrames(){
  const res = await fetch('/range.json', {cache:'no-store'});
  if(!res.ok) return;
  const j = await res.json();

  const startEl = document.getElementById('start');
  const endEl = document.getElementById('end');
  if(j.start) startEl.value = j.start;
  if(j.end)   endEl.value = j.end;

  // start/end を含むクエリを付与して iframe を更新(必ず再読み込みさせる)
  const v = encodeURIComponent(`${j.start||''}_${j.end||''}_${Date.now()}`);

  document.getElementById('f1').src = `/sample2.html?v=${v}`;
  document.getElementById('f2').src = `/rides.html?v=${v}`;
  document.getElementById('f3').src = `/bus_map.html?v=${v}`;
  document.getElementById('f4').src = `/trips_map.html?v=${v}`;
}

window.addEventListener('load', () => {
  syncRangeAndReloadFrames();
});

function saveSelected(){
  const sel = document.getElementById('shotTarget').value;
  const map = {f1:'sample2', f2:'rides', f3:'bus_map', f4:'trips_map'};
  saveFrame(sel, map[sel] || 'screen');
}

// 「いまiframeで表示しているURL(クエリ込み)」を /shot に渡す
async function saveFrame(frameId, label){
  const msg = document.getElementById('shotMsg');
  msg.textContent = '画像生成中...';

  const iframe = document.getElementById(frameId);
  if(!iframe){
    msg.textContent = '失敗: iframeが見つかりません';
    return;
  }

  const url = new URL(iframe.src);
  const pathWithQuery = url.pathname + url.search;

  const w = 1400, h = 900;
  const shotUrl = `/shot?path=${encodeURIComponent(pathWithQuery)}&w=${w}&h=${h}`;

  try{
    const res = await fetch(shotUrl, {cache:'no-store'});
    if(!res.ok){
      msg.textContent = `失敗: /shot HTTP ${res.status}`;
      return;
    }
    const blob = await res.blob();
    const a = document.createElement('a');
    const obj = URL.createObjectURL(blob);
    a.href = obj;
    a.download = `${label}_${nowStr()}.png`;
    a.click();
    URL.revokeObjectURL(obj);
    msg.textContent = '保存しました';
  }catch(e){
    msg.textContent = '失敗: 通信/生成エラー';
  }
}
</script>

</body>
</html>

5.2 server.py(全文)

# server.py
from __future__ import annotations

import os
import io
import csv
import datetime as dt
from dataclasses import dataclass

from flask import (
    Flask,
    request,
    make_response,
    send_from_directory,
    redirect,
    Response,
    abort,
    jsonify,
)

import psycopg2

APP_HOST = "0.0.0.0"
APP_PORT = int(os.getenv("SAMPLE2_PORT", "18080"))

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

# ========= DB 接続 =========
PGHOST = os.getenv("PGHOST", "localhost")
PGPORT = int(os.getenv("PGPORT", "15432"))
PGDATABASE = os.getenv("PGDATABASE", "tsubame_db")
PGUSER = os.getenv("PGUSER", "postgres")
PGPASSWORD = os.getenv("PGPASSWORD", "password")

# ========= cookie =========
COOKIE_START = "date_start"
COOKIE_END = "date_end"

# ========= table / column =========
STOP_TABLE = "stop_tsubame"
STOP_ID_COL = "stop_num"
STOP_NAME_COL = "name"
STOP_GEOM_COL = "geom"

LOG_TABLE = "log_pass_tsubame"
LOG_TIME_COL = "ride_time"
LOG_ONOFF_COL = "on_off"
LOG_GEOM_COL = "geom"

app = Flask(__name__, static_folder=None)


@dataclass(frozen=True)
class DateRange:
    start: dt.date
    end: dt.date

    @property
    def end_exclusive(self) -> dt.date:
        return self.end + dt.timedelta(days=1)


def parse_date(s: str) -> dt.date:
    return dt.datetime.strptime(s, "%Y-%m-%d").date()


def get_range(req) -> DateRange:
    s = req.cookies.get(COOKIE_START)
    e = req.cookies.get(COOKIE_END)

    # cookie が無い場合は「今月1日〜今日」
    if not s or not e:
        today = dt.date.today()
        start = today.replace(day=1)
        end = today
        return DateRange(start, end)

    start = parse_date(s)
    end = parse_date(e)
    if end < start:
        start, end = end, start
    return DateRange(start, end)


def db_connect():
    return psycopg2.connect(
        host=PGHOST,
        port=PGPORT,
        dbname=PGDATABASE,
        user=PGUSER,
        password=PGPASSWORD,
    )


def csv_response(filename: str, text: str, download: bool):
    resp = make_response(text)
    resp.headers["Cache-Control"] = "no-store"

    if download:
        resp.headers["Content-Type"] = "application/octet-stream"
        resp.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
    else:
        resp.headers["Content-Type"] = "text/csv; charset=utf-8"
        resp.headers["Content-Disposition"] = f'inline; filename="{filename}"'
    return resp


# ========= 画面 =========
@app.get("/")
def root():
    return send_from_directory(BASE_DIR, "index.html")


@app.get("/range.json")
def range_json():
    # index.html が cookie の値を取得するため
    dr = get_range(request)
    return jsonify({
        "start": dr.start.strftime("%Y-%m-%d"),
        "end": dr.end.strftime("%Y-%m-%d"),
    })


@app.post("/set_range")
def set_range():
    s = request.form["start"]
    e = request.form["end"]
    resp = redirect("/")
    resp.set_cookie(COOKIE_START, s, max_age=86400 * 365)
    resp.set_cookie(COOKIE_END, e, max_age=86400 * 365)
    return resp


# ========= 画面保存(PNG) =========
@app.get("/shot")
def shot():
    """
    /shot?path=/bus_map.html?v=...&w=1400&h=900

    - path は「/から始まるパス + クエリ」を許可(iframeの表示URLをそのまま渡す)
    - /shot に来た cookie(date_start/date_end)を Playwright に注入
      → ブラウザ表示と同じ期間で描画される
    """
    path = request.args.get("path", "")
    if not path.startswith("/"):
        abort(400)

    w = int(request.args.get("w", "1400"))
    h = int(request.args.get("h", "900"))
    if w < 200 or h < 200 or w > 5000 or h > 5000:
        abort(400)

    base = request.host_url.rstrip("/")  # 例: http://127.0.0.1:18080
    target_url = f"{base}{path}"

    try:
        from playwright.sync_api import sync_playwright
    except Exception:
        return Response(
            "Playwright が未導入です。サーバ側で `pip install playwright` と "
            "`playwright install chromium` を実行してください。",
            status=500,
            mimetype="text/plain; charset=utf-8",
        )

    # cookie を Playwright に注入
    date_start = request.cookies.get(COOKIE_START)
    date_end = request.cookies.get(COOKIE_END)

    try:
        with sync_playwright() as p:
            browser = p.chromium.launch()
            context = browser.new_context(viewport={"width": w, "height": h})

            cookies = []
            if date_start:
                cookies.append({"name": COOKIE_START, "value": date_start, "url": base})
            if date_end:
                cookies.append({"name": COOKIE_END, "value": date_end, "url": base})
            if cookies:
                context.add_cookies(cookies)

            page = context.new_page()
            page.goto(target_url, wait_until="networkidle")

            # JS描画(Chart/Leaflet等)待ちの保険
            page.wait_for_timeout(800)

            png = page.screenshot(full_page=True, type="png")

            context.close()
            browser.close()

    except Exception as e:
        return Response(
            f"shot failed: {e}",
            status=500,
            mimetype="text/plain; charset=utf-8",
        )

    resp = Response(png, mimetype="image/png")
    resp.headers["Cache-Control"] = "no-store"
    return resp


# ---------- sample2.csv ----------
@app.get("/sample2.csv")
def sample2_csv():
    dr = get_range(request)
    sql = f"""
    SELECT on_count, COUNT(*) AS num_psg
    FROM (
      SELECT psg_id, COUNT(*) AS on_count
      FROM {LOG_TABLE}
      WHERE {LOG_TIME_COL} >= %(s)s
        AND {LOG_TIME_COL} <  %(e)s
        AND {LOG_ONOFF_COL} = 1
      GROUP BY psg_id
    ) t
    GROUP BY on_count
    ORDER BY on_count
    """

    out = io.StringIO()
    w = csv.writer(out)
    w.writerow(["on_count", "num_psg", "cum_pct"])

    with db_connect() as c:
        with c.cursor() as cur:
            cur.execute(sql, {"s": dr.start, "e": dr.end_exclusive})
            rows = cur.fetchall()

    total = sum(r[1] for r in rows)
    cum = 0
    for oc, n in rows:
        cum += n
        pct = (cum / total * 100) if total else 0
        w.writerow([oc, n, f"{pct:.3f}"])

    return csv_response("sample2.csv", out.getvalue(), request.args.get("download") == "1")


# ---------- ride_summary.csv ----------
@app.get("/ride_summary.csv")
def ride_summary_csv():
    dr = get_range(request)
    sql = f"""
    SELECT {LOG_TIME_COL}::date,
           EXTRACT(hour FROM {LOG_TIME_COL})::int,
           COUNT(*)
    FROM {LOG_TABLE}
    WHERE {LOG_TIME_COL} >= %(s)s
      AND {LOG_TIME_COL} <  %(e)s
      AND {LOG_ONOFF_COL} = 1
      AND EXTRACT(hour FROM {LOG_TIME_COL}) BETWEEN 10 AND 13
    GROUP BY 1,2
    ORDER BY 1,2
    """

    data = {}
    with db_connect() as c:
        with c.cursor() as cur:
            cur.execute(sql, {"s": dr.start, "e": dr.end_exclusive})
            for d, h, n in cur.fetchall():
                key = d.strftime("%Y/%m/%d")
                data.setdefault(key, {10: 0, 11: 0, 12: 0, 13: 0})
                data[key][h] = n

    out = io.StringIO()
    w = csv.writer(out)
    w.writerow(["日付", "10時", "11時", "12時", "13時"])
    for d in sorted(data):
        w.writerow([d, data[d][10], data[d][11], data[d][12], data[d][13]])

    return csv_response("ride_summary.csv", out.getvalue(), request.args.get("download") == "1")


# ---------- bus_stops.csv ----------
@app.get("/bus_stops.csv")
def bus_stops_csv():
    dr = get_range(request)
    include_all = request.args.get("all") == "1"  # /bus_stops.csv?all=1 で全停留所

    sql = f"""
    WITH counts AS (
      SELECT
        s.{STOP_ID_COL} AS {STOP_ID_COL},
        SUM(CASE WHEN l.{LOG_ONOFF_COL} = 1 THEN 1 ELSE 0 END) AS board,
        SUM(CASE WHEN l.{LOG_ONOFF_COL} = 0 THEN 1 ELSE 0 END) AS alight
      FROM {LOG_TABLE} AS l
      JOIN LATERAL (
        SELECT {STOP_ID_COL}
        FROM {STOP_TABLE}
        ORDER BY {STOP_GEOM_COL} <-> l.{LOG_GEOM_COL}
        LIMIT 1
      ) AS s ON TRUE
      WHERE l.{LOG_TIME_COL} >= %(s)s
        AND l.{LOG_TIME_COL} <  %(e)s
      GROUP BY s.{STOP_ID_COL}
    )
    SELECT
      regexp_replace(st.{STOP_NAME_COL}, '^([0-9]+)\\s*[..]\\s*', '\\1. ') AS name,
      ST_Y(st.{STOP_GEOM_COL}) AS lat,
      ST_X(st.{STOP_GEOM_COL}) AS lng,
      COALESCE(c.board, 0)  AS board,
      COALESCE(c.alight, 0) AS alight
    FROM {STOP_TABLE} AS st
    LEFT JOIN counts AS c
      ON c.{STOP_ID_COL} = st.{STOP_ID_COL}
    {"WHERE c."+STOP_ID_COL+" IS NOT NULL" if not include_all else ""}
    ORDER BY st.{STOP_ID_COL};
    """

    out = io.StringIO()
    w = csv.writer(out)
    w.writerow(["name", "lat", "lng", "board", "alight"])

    with db_connect() as c:
        with c.cursor() as cur:
            cur.execute(sql, {"s": dr.start, "e": dr.end_exclusive})
            for name, lat, lng, board, alight in cur.fetchall():
                w.writerow([name, lat, lng, board, alight])

    return csv_response("bus_stops.csv", out.getvalue(), request.args.get("download") == "1")


# ---------- trips.csv ----------
@app.get("/trips.csv")
def trips_csv():
    dr = get_range(request)

    sql = f"""
WITH with_stop AS (
  SELECT
    l.psg_id,
    l.{LOG_TIME_COL} AS ride_time,
    l.{LOG_ONOFF_COL} AS on_off,
    (s.stop_num::text || '. ' ||
      regexp_replace(s.name, '^[0-9]+[.\\.]\\s*', '')
    ) AS stop_name
  FROM {LOG_TABLE} l
  JOIN LATERAL (
    SELECT stop_num, name, geom
    FROM {STOP_TABLE}
    ORDER BY l.{LOG_GEOM_COL} <-> geom
    LIMIT 1
  ) s ON true
  WHERE l.{LOG_TIME_COL} >= %(s)s
    AND l.{LOG_TIME_COL} <  %(e)s
),
seq AS (
  SELECT
    psg_id,
    ride_time,
    on_off,
    stop_name,
    SUM(CASE WHEN on_off = 1 THEN 1 ELSE 0 END)
      OVER (PARTITION BY psg_id ORDER BY ride_time) AS trip_no
  FROM with_stop
),
od AS (
  SELECT
    psg_id,
    trip_no,
    (array_agg(stop_name ORDER BY ride_time)
      FILTER (WHERE on_off = 1))[1] AS "from",
    (
      array_agg(stop_name ORDER BY ride_time)
        FILTER (WHERE on_off = 0)
    )[
      array_length(
        array_agg(stop_name ORDER BY ride_time)
          FILTER (WHERE on_off = 0)
      , 1)
    ] AS "to"
  FROM seq
  WHERE trip_no > 0
  GROUP BY psg_id, trip_no
)
SELECT
  "from",
  "to",
  COUNT(*) AS trips
FROM od
WHERE "from" IS NOT NULL
  AND "to" IS NOT NULL
  AND "from" <> "to"
GROUP BY "from", "to"
ORDER BY "from", "to";
"""

    out = io.StringIO()
    w = csv.writer(out)
    w.writerow(["from", "to", "trips"])

    with db_connect() as c:
        with c.cursor() as cur:
            cur.execute(sql, {"s": dr.start, "e": dr.end_exclusive})
            for f, t, n in cur.fetchall():
                w.writerow([f, t, n])

    return csv_response("trips.csv", out.getvalue(), request.args.get("download") == "1")


# ---------- HTML ----------
@app.get("/sample2.html")
def sample2_html():
    return send_from_directory(BASE_DIR, "sample2.html")


@app.get("/rides.html")
def rides_html():
    return send_from_directory(BASE_DIR, "rides.html")


@app.get("/bus_map.html")
def bus_map_html():
    return send_from_directory(BASE_DIR, "bus_map_from_csv_fix.html")


@app.get("/trips_map.html")
def trips_map_html():
    return send_from_directory(BASE_DIR, "trips_map.html")


# ---------- その他(静的ファイル) ----------
@app.get("/<path:filename>")
def any_file(filename):
    p = os.path.join(BASE_DIR, filename)
    if os.path.exists(p):
        return send_from_directory(BASE_DIR, filename)
    return Response("not found", 404)


if __name__ == "__main__":
    app.run(host=APP_HOST, port=APP_PORT)

6. 動作確認手順(再現手順)

  1. DB(PostgreSQL)が起動していること(既存の前提)

  2. Playwright を導入(初回のみ)

pip install playwright
playwright install chromium

3. server.py 起動

python3 server.py
  1. ブラウザでアクセス

  • http://127.0.0.1:18080/(ポートが違う場合は読み替え)

  1. 開始日・終了日を入力して「OK(表示)」

  2. 4画面が更新される(iframeが ?v=... で強制リロードされる)

  3. 「選択した画面をPNG保存」または各パネルの「PNG保存」を押す

  4. 表示内容と一致するPNGがローカルに保存される


7. 最終的に効いたポイント(短縮メモ)

  • /shot に cookie を渡す(Playwrightへ add_cookies)

  • iframe は必ず再読み込みさせる(?v=start_end_timestamp

  • 保存対象は iframe の現在表示URL(pathname + search)

 

2025,江端さんの忘備録

ニュースで人命に関わる事件が発生した時に使われる定番のフレーズに、
There is a stock phrase commonly used in news reports when incidents involving human life occur,

『搬送時に会話ができる状態であり、命に別状はないようです』
“The person was conscious and able to converse during transport, and their life does not appear to be in danger.”

がありますが、私は言いたい
This phrase is often used, but I want to say,

―― だから何?
— So what?

と。
That’s what I feel.

-----

いや、もちろん、このニュースのフレーズは、事件の概要と深刻度を取り扱う最適なフレーズであることは認めます。
Of course, I acknowledge that this phrase is an optimal way to convey the outline and severity of an incident.

人命は、一度失われたら、後から取り返すことのできない、"No Return"の概念です。
Human life is a “No Return” concept—once it is lost, it can never be recovered.

これは、脳死判定の時に使われる「不可逆性」と同じです。
This is the same as the “irreversibility” used in brain death determination.

-----

ただ、先日骨折事故を起こし、現在もその痛みに苦しめられている私から言えば、『命に別状はなくても、その後、"月単位"、"年単位"、下手すれば終生、その後遺症(痛みや障害)に苦しむ』という事実が伝わらないことに、憤慨します。
However, speaking as someone who recently suffered a fracture accident and is still tormented by pain, I am outraged that the fact that even if one’s life is not in danger, one may suffer for “months,” “years,” or even a lifetime from aftereffects such as pain or disability, fails to be conveyed.

ただ、私が憤慨しているのは、『私が現在、回復期の途中であるとは言え、痛みに悩まされているから』であり、完全回復した後は、多分、高い確率でこの「憤慨したことすら忘れる」という確信があります。
That said, I am aware that my anger stems from the fact that I am currently still in the recovery phase and suffering pain, and I am pretty sure that once I have fully recovered, I will probably forget even having been outraged.

リハビリの為に、ウォーキングを始めたのですが、ウォーキングすると痛みが再発しました(ちょっとビックリしました)。といって、ウォーキングしないと、いつまで経ってもリハビリできない ーー ええい、本当に面倒くさい!
I started walking as part of my rehabilitation, but when I did, the pain returned (which surprised me a little). Yet, if I do not walk, rehabilitation will never progress—damn it, this is really troublesome!

「自分の体が回復しているかのように思っていたところに、また元の状態に戻らされる」というのは、なんともやりきれない思いです。
Being pushed back to the original state just when you thought your body was recovering is an utterly disheartening feeling.

術後の激痛の数週間、激痛から日常に戻るまで苦痛 ーー これが、『搬送時に会話ができる状態であり、命に別状はないようです』には、"ない"。
The weeks of intense postoperative pain and the suffering until returning to daily life are not included in “the person was conscious and able to converse, and their life is not in danger.”

理不尽だ、と思うのです。
I find this deeply unreasonable.

-----

今から思うのですが、『骨折手術 → 翌日退院』という現在の医療の凄さにビックリしました(まあ、骨折事故の種類もあるでしょうが)。
Looking back now, I was astonished by the modern medical practice of “fracture surgery → discharge the next day” (though of course this depends on the type of fracture).

もちろん、私は病院に長期滞在したい訳ではないのですが、嫁さんの介護なしに何もできない状態で、自宅に戻らされる ーー そして、強力な鎮痛剤と嫁さんの介護で、なんとかその日を凌ぐという、この医療環境に、心底驚きました。
Of course, I did not want to stay in the hospital long-term, but being sent home in a state where I could do nothing without my wife’s care—and somehow surviving each day with strong painkillers and her support—truly shocked me.

今や病院は、必要最低限のことをすれば、「後は自分でやりなさい」を原則として動いています。間違いありません。私は我が国の医療予算の厳しさを、骨折の痛みと合わせて、身に染みるほど実感しました。
Today, hospitals operate on the principle that once the bare minimum is done, “the rest is up to you.” There is no doubt about that. Along with the pain of my fracture, I felt the severity of our country’s medical budget constraints deep in my bones.

皆さんも覚悟してください ーー 病院はできることだけをやり、自分できることは自分でやる ーー 片腕でメシを喰い、利き腕の逆の手で文字を書き、激痛を感じないように低速(秒速10cm)の歩行をし、寝返りで激痛で目がさめる、痛みを感じない置き上がりのルートに10分以上を要する ーー という日々を、『自宅で、自力で行う』という医療体勢にシフトしています。
You should all be prepared—the hospital does only what it can, and what you can do, you must do yourself—eating with one arm, writing with the non-dominant hand, walking slowly (10 cm per second) to avoid severe pain, waking up from agony when turning over, taking more than ten minutes to find a way to get up without pain—this is the medical system shifting toward having patients do all of this at home, on their own.

覚悟して下さい。事故の責任の所在とは無関係に、事故に巻き込まれれば、このような地獄が待っています。
Be prepared. Regardless of who is responsible, if you are involved in an accident, this kind of hell awaits you.

大切なのは、事故を起さないこと。事故を起こしにくい体を作っておくこと。できれば、タバコも酒も今止めてしまいましょう。
What matters most is not having an accident. Build a body that reduces the risk of accidents. If possible, quit smoking and drinking right now.

国家は助けてくれるけど、優しくはないぞ。
The state will help you, but it will not be gentle.

-----

「江端さんは、これまでずっと忙しい日々を送ってきたのですが、今回の入院でゆっくり静養して下さい」
“Mr. Ebata, you have led a hectic life until now, so please take this hospitalization as a chance to rest properly.”

と、色々な人から言われました
Many people said to me the above.

うん、このセリフ定番です。
Yes, this line is a classic.

でもね、あの入院直後の激痛の日々を回避できるなら、私は、通常の仕事を10倍にしても避けたかったですよ。
But if I could have avoided those days of excruciating pain immediately after hospitalization, I would have gladly taken on ten times my usual workload to do so.

事故の後の入院または治療の不便で激痛の日々が、それ以前の日々より"良い"なんてことは、絶対にありませんから。
There is absolutely no way that days of severe pain and inconvenience from hospitalization or treatment after an accident are “better” than the days before it.

「骨折で手術して麻酔が切れた時、どうしてずっと麻酔を投与し続けてくれないんだ、と思いました。意識なく痛みを感じないまま体を治癒できれば、不要に体を動かすことなく治癒が進むと思いますし、もし痛みが永久に続くのであれば、意識を回復したくない、と思いました」とChatGPTと話をしてみた件

未分類

1. 作業目的

既存の日時指定ダッシュボード(sample2 / rides / bus_map)に対して、

  1. 停留所間トリップ(OD)を可視化する4つ目の画面 trips_map.html を追加

  2. その描画に必要な trips.csv を、他CSVと同様に「指定期間内」でDBから動的生成

  3. 既存UI(index.html)を壊さず、iframe + CSVダウンロードボタンを追加

  4. 表示崩れ(左寄り)を修正し、視認性を改善

することを目的とした。


2. 最終的な画面構成

index.html(トップ)

  • 日時指定フォーム

  • CSVダウンロードボタン(4種)

    • sample2.csv

    • ride_summary.csv

    • bus_stops.csv

    • trips.csv(追加)

  • iframe 表示(上から順に)

    1. sample2(利用回数分布)

    2. rides(日別・時間帯別ライド数)

    3. bus_map(停留所マップ)

    4. trips_map(停留所間トリップ可視化)


3. 実装の要点

3.1 trips.csv の仕様

  • 期間指定:index.html の start/end → cookie → SQL WHERE に反映

  • 定義

    • from:同一トリップ内の最初の乗車停留所

    • to:同一トリップ内の最後の降車停留所

    • trips:その OD の件数

  • トリップ定義

    • psg_id 単位

    • on_off = 1(乗車)が出現するたびに新しいトリップ


3.2 表示ずれ(左寄り)の原因と対処

  • 原因:max-width のみ指定され、中央寄せ指定がなかった

  • 対処:

    margin: 0 auto;

    をラッパー要素に追加


4. 最終成果物(コード全文)


4.1 server.py(全文)

from __future__ import annotations

import os
import io
import csv
import datetime as dt
from dataclasses import dataclass

from flask import (
    Flask,
    request,
    make_response,
    send_from_directory,
    redirect,
    Response,
)
import psycopg2

APP_HOST = "0.0.0.0"
APP_PORT = int(os.getenv("SAMPLE2_PORT", "18080"))
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

# ========= DB 接続 =========
PGHOST = os.getenv("PGHOST", "localhost")
PGPORT = int(os.getenv("PGPORT", "15432"))
PGDATABASE = os.getenv("PGDATABASE", "tsubame_db")
PGUSER = os.getenv("PGUSER", "postgres")
PGPASSWORD = os.getenv("PGPASSWORD", "password")

# ========= cookie =========
COOKIE_START = "date_start"
COOKIE_END = "date_end"

STOP_TABLE = "stop_tsubame"
LOG_TABLE = "log_pass_tsubame"

app = Flask(__name__, static_folder=None)


@dataclass(frozen=True)
class DateRange:
    start: dt.date
    end: dt.date

    @property
    def end_exclusive(self):
        return self.end + dt.timedelta(days=1)


def parse_date(s: str) -> dt.date:
    return dt.datetime.strptime(s, "%Y-%m-%d").date()


def get_range(req) -> DateRange:
    s = req.cookies.get(COOKIE_START)
    e = req.cookies.get(COOKIE_END)

    if not s or not e:
        today = dt.date.today()
        return DateRange(today.replace(day=1), today)

    d1 = parse_date(s)
    d2 = parse_date(e)
    return DateRange(min(d1, d2), max(d1, d2))


def db_connect():
    return psycopg2.connect(
        host=PGHOST,
        port=PGPORT,
        dbname=PGDATABASE,
        user=PGUSER,
        password=PGPASSWORD,
        sslmode="disable",
    )


def csv_response(name, text, download):
    r = make_response(text)
    r.headers["Cache-Control"] = "no-store"
    if download:
        r.headers["Content-Disposition"] = f'attachment; filename="{name}"'
        r.headers["Content-Type"] = "application/octet-stream"
    else:
        r.headers["Content-Type"] = "text/csv; charset=utf-8"
    return r


@app.get("/")
def root():
    return send_from_directory(BASE_DIR, "index.html")


@app.post("/set_range")
def set_range():
    s = request.form["start"]
    e = request.form["end"]
    r = redirect("/")
    r.set_cookie(COOKIE_START, s, max_age=86400 * 365)
    r.set_cookie(COOKIE_END, e, max_age=86400 * 365)
    return r


# ---------- trips.csv ----------
@app.get("/trips.csv")
def trips_csv():
    dr = get_range(request)

    sql = """
WITH with_stop AS (
  SELECT
    l.psg_id,
    l.ride_time,
    l.on_off,
    (s.stop_num::text || '. ' ||
     regexp_replace(s.name, '^[0-9]+[.\\.]\\s*', '')
    ) AS stop_name
  FROM log_pass_tsubame l
  JOIN LATERAL (
    SELECT stop_num, name, geom
    FROM stop_tsubame
    ORDER BY l.geom <-> geom
    LIMIT 1
  ) s ON true
  WHERE l.ride_time >= %(s)s
    AND l.ride_time <  %(e)s
),
seq AS (
  SELECT
    psg_id,
    ride_time,
    on_off,
    stop_name,
    SUM(CASE WHEN on_off = 1 THEN 1 ELSE 0 END)
      OVER (PARTITION BY psg_id ORDER BY ride_time) AS trip_no
  FROM with_stop
),
od AS (
  SELECT
    psg_id,
    trip_no,
    (array_agg(stop_name ORDER BY ride_time)
      FILTER (WHERE on_off = 1))[1] AS "from",
    (
      array_agg(stop_name ORDER BY ride_time)
        FILTER (WHERE on_off = 0)
    )[
      array_length(
        array_agg(stop_name ORDER BY ride_time)
          FILTER (WHERE on_off = 0)
      , 1)
    ] AS "to"
  FROM seq
  WHERE trip_no > 0
  GROUP BY psg_id, trip_no
)
SELECT "from","to",COUNT(*) AS trips
FROM od
WHERE "from" IS NOT NULL
  AND "to" IS NOT NULL
  AND "from" <> "to"
GROUP BY "from","to"
ORDER BY "from","to";
"""

    out = io.StringIO()
    w = csv.writer(out)
    w.writerow(["from", "to", "trips"])

    with db_connect() as c:
        with c.cursor() as cur:
            cur.execute(sql, {"s": dr.start, "e": dr.end_exclusive})
            for f, t, n in cur.fetchall():
                w.writerow([f, t, n])

    return csv_response("trips.csv", out.getvalue(),
                        request.args.get("download") == "1")


@app.get("/trips_map.html")
def trips_map_html():
    return send_from_directory(BASE_DIR, "trips_map.html")


@app.get("/<path:filename>")
def any_file(filename):
    p = os.path.join(BASE_DIR, filename)
    if os.path.exists(p):
        return send_from_directory(BASE_DIR, filename)
    return Response("not found", 404)


if __name__ == "__main__":
    app.run(host=APP_HOST, port=APP_PORT)

4.2 index.html(全文・4画面対応)

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>日時指定ダッシュボード(4画面)</title>
<style>
body{font-family:sans-serif;margin:16px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.panel{margin-top:16px;border:1px solid #ccc;border-radius:8px;overflow:hidden}
.panel h2{margin:0;padding:8px;background:#f5f5f5}
iframe{width:100%;border:0}
.h1,.h2{height:640px}
.h3,.h4{height:720px}
a.btn{padding:6px 10px;border:1px solid #888;text-decoration:none}
</style>
</head>
<body>

<h1>日時指定(4表示)</h1>

<form method="POST" action="/set_range" class="row">
  <label>開始日 <input type="date" name="start" required></label>
  <label>終了日 <input type="date" name="end" required></label>
  <button type="submit">OK(表示)</button>

  <a class="btn" href="/sample2.csv?download=1">CSV(sample2)</a>
  <a class="btn" href="/ride_summary.csv?download=1">CSV(rides)</a>
  <a class="btn" href="/bus_stops.csv?download=1">CSV(bus_stops)</a>
  <a class="btn" href="/trips.csv?download=1">CSV(trips)</a>
</form>

<div class="panel">
<h2>sample2</h2>
<iframe class="h1" src="/sample2.html"></iframe>
</div>

<div class="panel">
<h2>rides</h2>
<iframe class="h2" src="/rides.html"></iframe>
</div>

<div class="panel">
<h2>bus_map</h2>
<iframe class="h3" src="/bus_map.html"></iframe>
</div>

<div class="panel">
<h2>trips_map</h2>
<iframe class="h4" src="/trips_map.html"></iframe>
</div>

</body>
</html>

4.3 sample2.html(中央寄せ修正含む・該当部分)

#wrap {
  max-width: 1100px;
  margin: 0 auto;
}

5. 最終確認結果

  • ✅ trips_map.html が iframe に表示される

  • ✅ trips.csv ボタンが表示・ダウンロード可能

  • ✅ trips.csv は 指定期間内データのみ

  • ✅ 上段グラフの左寄り問題を解消

  • ✅ 既存画面・既存CSVへの影響なし


6. 総括

本日の作業により、

  • 停留所間トリップ(OD)という新しい分析軸

  • 既存ダッシュボードと同一UX・同一期間指定

  • CSV ⇄ 可視化 ⇄ 再利用 が可能な構成

が完成した。
構造的に拡張しやすく、今後は

  • trips に応じた線の太さ・色分け

  • 特定停留所の強調表示

  • OD の時間帯別分解

なども容易に追加できる状態である。

以上が、本日の最終レポートである。

未分類

メモ残しておかんと、青冷めるので残しておく。

WITH with_stop AS (
  SELECT
    l.psg_id,
    l.ride_time,
    l.on_off,
    -- stop_tsubame.name 先頭の「番号+(. or .)」を落として、
    -- stop_num から "N. " を付け直す(=> "1. 平高山" 形式)
    (s.stop_num::text || '. ' ||
      regexp_replace(s.name, '^[0-9]+[.\.]\s*', '')
    ) AS stop_name
  FROM log_pass_tsubame l
  JOIN LATERAL (
    SELECT stop_num, name, geom
    FROM stop_tsubame
    ORDER BY l.geom <-> geom
    LIMIT 1
  ) s ON true
),
seq AS (
  SELECT
    psg_id,
    ride_time,
    on_off,
    stop_name,
    -- 乗車(1)が出たら trip_no を +1(psg_id 内で乗車ごとにトリップ区切り)
    SUM(CASE WHEN on_off = 1 THEN 1 ELSE 0 END)
      OVER (PARTITION BY psg_id ORDER BY ride_time) AS trip_no
  FROM with_stop
),
od AS (
  SELECT
    psg_id,
    trip_no,
    -- 同一 trip_no の最初の乗車停留所
    (array_agg(stop_name ORDER BY ride_time)
      FILTER (WHERE on_off = 1))[1] AS "from",
    -- 同一 trip_no の最後の降車停留所(降車が複数あっても最後を採用)
    (
      array_agg(stop_name ORDER BY ride_time)
        FILTER (WHERE on_off = 0)
    )[
      array_length(
        array_agg(stop_name ORDER BY ride_time)
          FILTER (WHERE on_off = 0)
      , 1)
    ] AS "to"
  FROM seq
  WHERE trip_no > 0
  GROUP BY psg_id, trip_no
)
SELECT
  "from",
  "to",
  COUNT(*) AS trips
FROM od
WHERE "from" IS NOT NULL
  AND "to" IS NOT NULL
  AND "from" <> "to"
GROUP BY "from", "to"
ORDER BY "from", "to";

これで直る点

  • 表示が 1. 1.平高山 のように重複せず、1. 平高山 形式になります

  • 「降車→次の降車」ではなく、「乗車→降車」OD になります(提示されたCSVの形に一致するはず)


もし「期間指定」も当時入れていたなら、with_stop に条件を足します(例)。

WHERE l.ride_time >= '2025-01-01'::timestamp
  AND l.ride_time <  '2025-02-01'::timestamp

2025,江端さんの忘備録

「結婚はコスパが悪い」という見出しの記事がよく登場しています。
Articles with headlines claiming that “marriage has poor cost performance” appear frequently.

若者を「擁護」または「批判」のいずれかの観点で論じられていますが、もはや、その観点すら「陳腐」です。
They tend to discuss young people either by “defending” or “criticizing” them, but even that framing has already become trite.

ちょっと考えてみたのですが、そもそも「コスパが良いもの」って何だろう、というところから考え直してみました。
I gave it some thought and decided to reconsider the issue, starting from a more basic question: what exactly does it mean for something to be “cost-effective”?

-----

「コスパが良いもの」とは何でしょうか。支払ったコストに対して、得られるリターンが大きく、しかも安定して予測可能なもの、という定義になるでしょう。
What, then, is something that is “cost-effective”? It would likely be defined as something that yields a significant return relative to the cost paid and whose outcome can be predicted reliably.

しかし、この定義を人生の選択に当てはめた瞬間、話はおかしくなります。
However, the moment we apply this definition to life choices, the argument begins to fall apart.

なぜなら、人生における主要な選択――進学、就職、転職、結婚、子育て、介護――のいずれも、投入コストは事前に見積もれても、リターンはほぼ確実に予測不能だからです。
This is because for all major life decisions—education, employment, job changes, marriage, childrearing, caregiving—the costs can be estimated in advance, but the returns are almost certainly unpredictable.

結婚にしても同じです。
The same applies to marriage.

結婚によって得られる幸福や安定は、事前に数値化できませんし、再現性もありません。
The happiness or stability gained through marriage cannot be quantified beforehand, nor can it be reproduced reliably.

むしろ、予期せぬ負担や衝突、責任が増える構造を内包している、と言った方が正確でしょう。
If anything, it would be more accurate to say that marriage structurally contains an increase in unexpected burdens, conflicts, and responsibilities.

つまり、「結婚はコスパが悪い」という主張は、結婚だけを特別に切り出している点で、論理的にフェアではありません。
In other words, the claim that “marriage has poor cost performance” is not logically fair, because it singles out marriage alone.

-----

正確に言えば、「人生を構成するほぼすべての重要イベントは、コスパが悪い」のです。
To be precise, almost all important events that make up a human life are poor in terms of cost performance.

それでも人は、結婚し、子を持ち、誰かのために時間と労力を費やします。
Even so, people marry, have children, and devote time and effort to others.

それは、コスパという尺度が、人生の意思決定を測る物差しとして、決定的に不適合だからです。
That is because cost performance is fundamentally unsuited as a measure for life decisions.

コスパとは、本来「代替可能で、やらなくても困らない行為」に対して使う概念です。
Cost performance is, by nature, a concept meant for actions that are replaceable and non-essential.

スマホの機種選び、サブスクの解約、昼食の店選び。
Choosing a smartphone model, canceling a subscription, deciding where to eat lunch.

そうした「選ばなくても人生が続くもの」にしか、本質的には適用できません。
Essentially, it can only be applied to things that do not determine the continuation of one’s life.

人生の中核にある選択ほど、代替不能で、取り消し不能で、そして説明不能です。
The more central a choice is to one’s life, the more irreplaceable, irreversible, and indescribable it becomes.

それらをコスパで裁ろうとした時点で、議論の前提が崩れているのです。
The moment we try to judge such choices by cost performance, the very premise of the discussion collapses.

この議論を突き詰めていけば、つまるところ、
If we push this argument to its logical extreme,

―― 人生はコスパが悪い
— Life itself has poor cost performance.

に至るだろう、と思います。
That is where we inevitably arrive.

人生をコスパ良く生きたければ、今すぐ『人生を止める』が正解でしょう。
If one truly wanted to live life in a cost-effective way, the correct answer would be to stop living immediately.

とまあ、こんな風に問題を極論に展開して、汎化してウヤムヤにするのは、私の悪い癖です。
Turning an issue into an extreme argument, generalizing it, and then blurring it out like this is one of my bad habits.

だから、そういう語り方は、止めます。
So I will stop speaking about it in that way.

-----

実際のところ、私は「結婚のメリットは説明不能であり」、かつ「先人である既婚者は、結婚のメリットを言語化するのに、ことごとく失敗している」し、そして「結婚はデメリットで説明するのが、とても簡単である」という話をしてきました。
In reality, I have argued that the benefits of marriage are indescribable, and that married predecessors have almost universally failed to verbalize them. Marriage is effortless to explain in terms of its disadvantages.

つまり、人生のイベントにおいて、結婚は、かなり扱いやすい「題材」または「道具」なんですよね。
In that sense, among life events, marriage is a rather convenient “subject” or “tool” to handle.

どうも私には、「結婚はコスパが悪い」という言い回しが、ロジックのすりかえ、あるいは、キレイに整えすぎた抽象化に見えてしまいます。
To me, the phrase “marriage has poor cost performance” looks like a sleight of logic, or an overly polished abstraction.

はっきり言えばいい。
Let’s say it plainly.

(1)「結婚は難しい」
(1) “Marriage is difficult.”

いや、冗談抜きに、ちゃんと調べれば分かることですが、現代の結婚は、恐ろしく難しいのです。
No joking here—anyone who looks into it properly can see that marriage today is extremely difficult.

主に経済的要因が大きいのですが、その後に作られる人間関係の調整や維持を考えると、「1人で生きるのすら難しいのに、2人で生きることの難しさは半端ではない」ことは、思い込みではなく、構造的な事実だと思います。
Economic factors play a significant role, but when you consider the adjustment and maintenance of the human relationships that follow, the fact that “living alone is already difficult, so living as two is far more so” is not an assumption but a structural reality.

(2)「結婚は怖い」
(2) “Marriage is frightening.”

昔は「結婚しないことが怖い」でしたが、今や「結婚をする方が怖い」です。
In the past, it was frightening not to marry; now, it is more frightening to marry.

かつては、「結婚しないこと」は生存戦略に直結していました。結婚することで相互扶助が成立し、食料供給の担保がなされ、子どもという次世代の担い手を得る――結婚は一種の安全保障の枠組みだったのです。
In the past, not marrying was directly tied to survival strategy. Marriage established mutual support, secured a food supply, and produced children as the next generation of bearers—marriage functioned as a security framework.

ところが今は、その「結婚という安全保障の枠組み」は必須ではありません。率直に言えば、国家が相当な額の税を徴収する一方で、医療制度や社会保障制度は、国際比較で見ても、少なくとも先進国の中では相対的に手厚い部類に入ります。
Today, however, that “marriage-based security framework” is no longer essential. While the state collects substantial taxes, its healthcare and social security systems are, by international standards, relatively generous among developed countries.

ニュースでは制度の不備や、こぼれ落ちる人々が取り上げられていますが、それが「ニュースになる規模」に抑え込まれている、と見ることもできます。
The news highlights flaws in these systems and those who fall through the cracks, but it can also be seen that such cases are kept within a scale that remains “newsworthy.”

例えば、日本では毎日50~70人が自殺し、交通事故による死亡も平均すれば毎日7人前後は発生していますが、これらが日々のニュースとして逐一報じられることは多くありません。このような事象を数の相対論で語ることの是非については批判されてもよいと思いますが、これは事実です。
For example, in Japan, 50 to 70 people die by suicide every day, and traffic accidents claim around seven lives per day on average. Yet, these figures are not reported individually as daily news. One may criticize the validity of discussing such matters through numerical relativism, but these are facts.

そのような状況を鑑みれば、結婚とは、あえて不要なリスクを拾いにいく行為だ、と感じる人が増えるのも無理はありません。
Given this situation, it is understandable that more people feel marriage is an act of deliberately taking on unnecessary risk.

つまり結婚とは、「難しい」、そして「怖い」というだけの話だと思うのです。
In short, marriage is simply something that is “difficult” and “frightening.”

わざわざ「コスパ」などと言い換えなくても。
There is no need to rephrase it in terms of “cost performance.”

-----

だからこそ、結婚が「難しい」や「怖い」を超える、別方向の価値を、結婚を経験した人が説明しなければならないのですが、それが恐ろしく難しいのです――少なくとも、私はその試みに失敗しています。
That is precisely why those who have experienced marriage must explain the value that goes beyond “difficulty” and “fear,” but that task is challenging—at least, I have failed at it.

なぜなら、結婚は「難しい」と「怖い」の裏付けだけは、しっかり取れている、という現実があるからです。
This is because the evidence supporting marriage as “difficult” and “frightening” is firmly in place.

まあ、だからこの問題は、「打つ手なし」で行くしかないのだろう、と本気で思っています。
So, I seriously think that this problem may be one with “no effective solution.”

-----

野党の国会議員
Opposition lawmaker

「総理。総理は昨今の少子化問題に対して、本気で取り組んでいるのですか。この“成果のなさ”は一体なんですか!」
“Prime Minister, are you truly addressing the recent declining birthrate issue? What is the reason for this complete lack of results?”

総理
Prime Minister

「率直に言えば、『打つ手なし』が正直な感想です」
“To be honest, my candid impression is that there is no effective solution.”

と言えれば、総理大臣もラクだろうなぁ、と思うのですが――もちろん、そんなことは言えないでしょう。
If a prime minister could say that, life would be much easier—but of course, such a statement would never be allowed.

(内閣が倒れます)
(The cabinet would collapse.)

―― 『結婚の価値、良さ、メリット』を、若者に対して言語化できない、私(たち)の無能

2025,江端さんの忘備録

先日の人間ドックで、ついに、視力が両方1.0を下回りました。0.9と0.6です。
At my recent medical checkup, my visual acuity finally fell below 1.0 in both eyes: 0.9 and 0.6.

まあ、老眼鏡 ーー もとい、「リーディンググラス」なしでは、生活できない体(目)なので、まだマシなほうかもしれませんが。
Well, since I can no longer live without reading glasses—no, “reading glasses,” to be precise—this may still be on the better side.

私、リーディンググラスは、100均のものを使っていました。
I had been using reading glasses bought at a 100-yen shop.

これって、きちんとした眼鏡を作るべきだ、という意見がありますが、一度ちゃんと作ったことがあるのですが、ぶっちゃけ、この眼鏡が『役に立たなかった』。
Some people say you should get proper prescription glasses, and I did try that once, but frankly, those glasses were “not useful.”

私の視力は毎日変動する(基本的には悪化する方向に)ので、定期的に眼鏡を交換していく必要があり、ちゃんと作った眼鏡の方が、扱いが面倒なんですよ。
My eyesight fluctuates daily (basically worsening), so I need to replace my glasses regularly, and properly made ones are actually more troublesome to deal with.

-----

ただ、100均の眼鏡は、耐久性が弱い。簡単に壊れるので、これはそれで面倒なのです。
That said, 100-yen glasses are not durable. They break easily, which is troublesome in its own way.

で、どうしたかというと、Amazonで購入しています。
So what did I do? I buy them on Amazon.


(リンク先に飛びます)。
(You’ll be taken to the link.)

2000円くらいですね。100均の20倍くらいという、超高級品です。
They cost about 2,000 yen—roughly 20 times the price of a 100-yen item, an authentic luxury product.

この丸眼鏡、気に入っているんです。2つ購入しました。
I like these round glasses. I bought two of them.

これで、丸刈りにしたら ーー 鏡の前に、東京裁判A級戦犯の東条首相が立っていて、驚きました。
When I shaved my head with these on, I was startled to see Prime Minister Tojo, an A-class war criminal from the Tokyo Trials, standing in front of the mirror.

# 東京裁判における「事後法適用(遡及処罰)の問題」については、別のページを探して下さい。
# For the issue of ex post facto application of law (retroactive punishment) in the Tokyo Trials, please look it up on another page.

-----

私は目付きが悪く、加えて目の下にクマが常駐しているのですが、この眼鏡でごまかせることが分かりました。
I have an unfriendly look in my eyes and permanent dark circles under them, but I found that these glasses help disguise them.

さらに、長女から、結婚式に際して「髪の毛を染めてくること」を命じられ、これが、なんとなく『年齢をごまかせる』気がしたので、それから、定期的に髪を染めて、こんな形相で生活しています。
Furthermore, my eldest daughter ordered me to dye my hair for her wedding, and since that somehow made me feel I could “fake my age,” I’ve been dyeing my hair regularly and living with this appearance.

-----

先日、Amazonで購入したメガネの鼻パッドが外れていることが分かり、新しいのを購入するか迷っていたのですが ―― 『日常品の修理は、私の趣味』でもあるので、直してみることにしました。
The other day, I noticed that the nose pads of the glasses I bought on Amazon had come off. I hesitated about buying new ones, but since “repairing everyday items is my hobby,” I decided to fix them.

[Aquafix] メガネ鼻パッド シリコン製 メガネ修理キット 眼鏡 鼻あて 交換セット ドライバー ネジ クロス ピンセット ケース 付き パッド跡軽減 メガネずり落ち防止 ズレ防止

この395円のセットだけで、工具が入っており、鼻パッドも50個くらい入っていたので、まあ、死ぬまで在庫は持ちそうです。
This 395-yen set alone included tools and about 50 nose pads, so I’ll probably have enough stock for the rest of my life.

2つの眼鏡が、それぞれ壊れていたので、曲がっていた金具をラジオペンチで直して、鼻パッドを装着しました。
Since both pairs of glasses were damaged, I straightened the bent metal parts with radio pliers and attached the nose pads.

-----

丸眼鏡、気に入っていますので、もう一つ購入しようかとも思っています。こんなヤツです。
I like round glasses, so I’m thinking of buying another pair—something like this.

なんか、明治時代の「博士」がつけていたような、レトロな感じしません?
Doesn’t it feel retro, like something a “professor” from the Meiji era would wear?

なにしろ、私ってば、「博士」ですから(今年の9月17日から)。
After all, I am a “doctor”?starting September 17 this year.

昨日から、鏡の前に立つたびに、東条英機元首相が登場して、ビックリしています。