Download model.py from zyan1deOG/nika5: direct link, hf CLI and curl.
- Browser
- Download file 9.8 kB
-
https://huggingface.co/zyan1deOG/nika5/resolve/main/model.py
- Command line
-
hf download hf://zyan1deOG/nika5/model.py
-
curl -L -o model.py https://huggingface.co/zyan1deOG/nika5/resolve/main/model.py
9.8 kB
| """nika-5: grid β latent-mesh world model of the Sun. Pure torch. | |
| Observations live on a dense grid; state lives on a coarse mesh. One encoder | |
| shared across all AIA channels conditioned on log-T, one for the field vector. | |
| The readout is a function of (cell latent, log-T): the slider is the readout. | |
| """ | |
| from dataclasses import dataclass | |
| import torch | |
| import torch.nn as nn | |
| from lib import AIA_CHANNELS, AIA_SCALE, B_SCALE, LOGT, fourier, mlp, norm_logt, scatter_mean | |
| # ββ substrate: regular lattices, analytic connectivity βββββββββββββββ | |
| class SolarGraph: | |
| grid_pos: torch.Tensor # [G, 2] | |
| mu: torch.Tensor # [G] cos heliocentric angle (0 off-disk) | |
| g2m: torch.Tensor # [Eg, 2] (grid, mesh): 4 surrounding mesh nodes | |
| g2m_feat: torch.Tensor # [Eg, 3] relative geometry | |
| m2m: torch.Tensor # [Em, 2] directed: 8-neighbor + strides 2, 4 | |
| m2m_feat: torch.Tensor # [Em, 3] | |
| n_mesh: int | |
| def to(self, device): | |
| moved = {f: getattr(self, f).to(device) | |
| for f in ("grid_pos", "mu", "g2m", "g2m_feat", "m2m", "m2m_feat")} | |
| return SolarGraph(**moved, n_mesh=self.n_mesh) | |
| def _lattice(side): | |
| ax = (torch.arange(side) + 0.5) / side | |
| y, x = torch.meshgrid(ax, ax, indexing="ij") | |
| return torch.stack([x.flatten(), y.flatten()], -1) | |
| def _mesh_edges(side, offsets): | |
| idx = torch.arange(side * side).view(side, side) | |
| src, dst = [], [] | |
| for dy, dx in offsets: | |
| s = idx[max(0, -dy): side - max(0, dy), max(0, -dx): side - max(0, dx)] | |
| d = idx[max(0, dy): side + min(0, dy), max(0, dx): side + min(0, dx)] | |
| src.append(s.flatten()) | |
| dst.append(d.flatten()) | |
| e = torch.stack([torch.cat(src), torch.cat(dst)], -1) | |
| return torch.cat([e, e.flip(-1)]) | |
| def build_graph(res=256, mesh_side=48): | |
| grid = _lattice(res) | |
| r = ((grid - 0.5).square().sum(-1)).sqrt() / 0.5 | |
| mu = (1 - r.square()).clamp(min=0).sqrt() | |
| mesh = _lattice(mesh_side) | |
| near = [(0, 1), (1, 0), (1, 1), (1, -1)] | |
| far = [(0, 2), (2, 0), (0, 4), (4, 0)] | |
| m2m = _mesh_edges(mesh_side, near + far) | |
| scale = 1.0 / mesh_side | |
| rel = (mesh[m2m[:, 0]] - mesh[m2m[:, 1]]) / scale | |
| m2m_feat = torch.cat([rel, rel.norm(dim=-1, keepdim=True)], -1) | |
| cell = grid * mesh_side - 0.5 | |
| base = cell.floor().long() | |
| corners = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]]) | |
| m_yx = (base[:, None] + corners[None]).clamp(0, mesh_side - 1) # [G,4,2] | |
| m_idx = (m_yx[..., 1] * mesh_side + m_yx[..., 0]).flatten() | |
| g_idx = torch.arange(len(grid)).repeat_interleave(4) | |
| g2m = torch.stack([g_idx, m_idx], -1) | |
| rel = (grid[g_idx] - mesh[m_idx]) / scale | |
| g2m_feat = torch.cat([rel, rel.norm(dim=-1, keepdim=True)], -1) | |
| return SolarGraph(grid, mu, g2m, g2m_feat, m2m, m2m_feat, n_mesh=mesh_side**2) | |
| # ββ observations βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Observation: | |
| aia: torch.Tensor # [B, 9, G] asinh-normalized | |
| field: torch.Tensor # [B, 3, G] tanh-normalized | |
| present: torch.Tensor # [B, 9] bool | |
| def pack(frames, res=256): | |
| pool = lambda x: torch.as_tensor(x).view(res, 512 // res, res, 512 // res).mean((1, 3)) | |
| aia = torch.stack([ | |
| torch.stack([pool(f[c]) if c in f else torch.zeros(res, res) for c in AIA_CHANNELS]) | |
| for f in frames]).flatten(2) | |
| field = torch.stack([ | |
| torch.stack([pool(f[c]) for c in ("Bx", "By", "Bz")]) for f in frames]).flatten(2) | |
| present = torch.tensor([[c in f for c in AIA_CHANNELS] for f in frames]) | |
| return Observation(torch.asinh(aia / AIA_SCALE[:, None]), torch.tanh(field / B_SCALE), present) | |
| def masked(self, hide): | |
| vis = self.present.clone() | |
| vis[:, hide] = False | |
| return Observation(self.aia * vis[:, :, None], self.field, vis) | |
| def to(self, device): | |
| return Observation(self.aia.to(device), self.field.to(device), self.present.to(device)) | |
| # ββ layers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ChannelEncoder(nn.Module): | |
| """One encoder for every AIA channel: (intensity, log-T) -> d. Sum over present.""" | |
| def __init__(self, d): | |
| super().__init__() | |
| self.net = mlp(2, d, d) | |
| def forward(self, aia, present): | |
| logt = norm_logt(LOGT.to(aia.device))[:, None].expand_as(aia) | |
| return (self.net(torch.stack([aia, logt], -1)) * present[:, :, None, None]).sum(1) | |
| class FieldEncoder(nn.Module): | |
| """Magnetic vector -> d. The causal modality gets its own pathway.""" | |
| def __init__(self, d): | |
| super().__init__() | |
| self.net = mlp(3, d, d) | |
| def forward(self, field): | |
| return self.net(field.transpose(1, 2)) | |
| class GeometryEmbed(nn.Module): | |
| """ΞΌ + Fourier position -> d. Static, computed once per graph.""" | |
| def __init__(self, d): | |
| super().__init__() | |
| self.net = nn.Linear(17, d) | |
| def forward(self, graph): | |
| return self.net(torch.cat([graph.mu[:, None], fourier(graph.grid_pos)], -1)) | |
| class GridToMesh(nn.Module): | |
| """Lift cell tokens onto the latent mesh along g2m edges.""" | |
| def __init__(self, d): | |
| super().__init__() | |
| self.edge = mlp(d + 3, d, d) | |
| def forward(self, cells, graph): | |
| g, m = graph.g2m[:, 0], graph.g2m[:, 1] | |
| feat = graph.g2m_feat.expand(cells.shape[0], -1, -1) | |
| return scatter_mean(self.edge(torch.cat([cells[:, g], feat], -1)), m, graph.n_mesh) | |
| class DynamicsRound(nn.Module): | |
| """One tick: field-gated messages update a velocity; state integrates it.""" | |
| def __init__(self, d, damp=0.9, dt=0.2): | |
| super().__init__() | |
| self.msg = mlp(2 * d + 9, d, d) | |
| self.gate = mlp(2 * d + 9, d // 2, 1) | |
| self.upd = mlp(2 * d, d, d) | |
| self.damp, self.dt = damp, dt | |
| def forward(self, h, v, edge_field, graph): | |
| src, dst = graph.m2m[:, 0], graph.m2m[:, 1] | |
| feat = graph.m2m_feat.expand(h.shape[0], -1, -1) | |
| pair = torch.cat([h[:, dst], h[:, src], feat, edge_field], -1) | |
| gate = torch.sigmoid(self.gate(pair)) | |
| agg = scatter_mean(gate * self.msg(pair), dst, graph.n_mesh) | |
| v = self.damp * v + self.upd(torch.cat([h, agg], -1)) | |
| return h + self.dt * v, v, gate.squeeze(-1) | |
| class MeshDynamics(nn.Module): | |
| """Second-order rollout over the mesh; exposes mean gates for physics probes.""" | |
| def __init__(self, d, rounds): | |
| super().__init__() | |
| self.rounds = nn.ModuleList(DynamicsRound(d) for _ in range(rounds)) | |
| def forward(self, h, field_mesh, graph): | |
| src, dst = graph.m2m[:, 0], graph.m2m[:, 1] | |
| edge_field = torch.cat([field_mesh[:, src], field_mesh[:, dst]], -1) | |
| v = torch.zeros_like(h) | |
| gates = [] | |
| for tick in self.rounds: | |
| h, v, g = tick(h, v, edge_field, graph) | |
| gates.append(g) | |
| return h, torch.stack(gates).mean(0) | |
| class MeshToGrid(nn.Module): | |
| """Project mesh state back to cell latents along the same edges.""" | |
| def __init__(self, d): | |
| super().__init__() | |
| self.edge = mlp(d + 3, d, d) | |
| def forward(self, h, n_cells, graph): | |
| g, m = graph.g2m[:, 0], graph.g2m[:, 1] | |
| feat = graph.g2m_feat.expand(h.shape[0], -1, -1) | |
| return scatter_mean(self.edge(torch.cat([h[:, m], feat], -1)), g, n_cells) | |
| class TemperatureReadout(nn.Module): | |
| """Intensity as a function of (cell latent, log-T). Query any temperature.""" | |
| def __init__(self, d_in, d): | |
| super().__init__() | |
| self.net = mlp(d_in + 1, d, 1, depth=3) | |
| def forward(self, cells, logt_q): | |
| q = norm_logt(logt_q.to(cells.device))[None, :, None, None] | |
| z = cells[:, None].expand(-1, len(logt_q), -1, -1) | |
| return self.net(torch.cat([z, q.expand(*z.shape[:3], 1)], -1)).squeeze(-1) | |
| # ββ model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Config: | |
| dim: int = 128 | |
| rounds: int = 6 | |
| res: int = 256 | |
| mesh_side: int = 48 | |
| class Nika5(nn.Module): | |
| def __init__(self, cfg: Config): | |
| super().__init__() | |
| self.cfg = cfg | |
| d = cfg.dim | |
| self.channels = ChannelEncoder(d) | |
| self.field = FieldEncoder(d) | |
| self.geometry = GeometryEmbed(d) | |
| self.norm = nn.LayerNorm(d) | |
| self.lift = GridToMesh(d) | |
| self.dynamics = MeshDynamics(d, cfg.rounds) | |
| self.project = MeshToGrid(d) | |
| # mesh context β grid skip β raw visible intensities β concurrent | |
| # channels alone carry real signal (the linear baseline proves it), | |
| # so the readout keeps a direct pathway to them. | |
| self.readout = TemperatureReadout(2 * d + 9, d) | |
| def forward(self, obs: Observation, graph: SolarGraph, logt_q): | |
| cells = self.norm(self.channels(obs.aia, obs.present) | |
| + self.field(obs.field) | |
| + self.geometry(graph)) | |
| state = self.lift(cells, graph) | |
| field_mesh = scatter_mean(obs.field.transpose(1, 2)[:, graph.g2m[:, 0]], | |
| graph.g2m[:, 1], graph.n_mesh) | |
| state, self.gates = self.dynamics(state, field_mesh, graph) | |
| context = self.project(state, cells.shape[1], graph) | |
| raw = (obs.aia * obs.present[:, :, None]).transpose(1, 2) | |
| return self.readout(torch.cat([context, cells, raw], -1), logt_q) | |