方程:二维可压缩层流 Navier–Stokes 方程
The equations: two-dimensional compressible laminar Navier–Stokes
先读方程,再数一数入口、出口和固壁各给几个条件,并指出每个条件来自方程的哪一部分。
Read the equations, then count the conditions at the inlet, the outlet and the wall, and say which part of the equations asks for each.
图 1.1:本页的问题全貌Figure 1.1: The problem at a glance · 图 1.2:面上的应力与热流Figure 1.2: Stress and heat on a face · 图 1.3:一个面上的两部分通量Figure 1.3: The two parts of the flux on a face · 图 1.4:每种边界要几个条件Figure 1.4: How many conditions each boundary takes · 图 1.5:本页在网站路线上的位置Figure 1.5: This page on the route through the site
从守恒律到本页离散的方程
From conservation laws to the equations discretised here
二维可压缩 Navier–Stokes 方程由流体的三条守恒律组成:质量守恒;动量守恒,即流体微团的 Newton 第二定律,力来自压力和黏性应力;总能量守恒,即热力学第一定律,热量由导热带入,功由压力和黏性应力做出。平面流动中动量有 、 两个分量,所以共有四个方程、四个守恒量。本页研究层流,不加湍流模型。把单位体积内的四个守恒量排成一列:
The two-dimensional compressible Navier–Stokes equations are the three conservation laws of a fluid: conservation of mass; of momentum, which is Newton's second law for a fluid parcel, with forces from pressure and from viscous stress; and of total energy, the first law of thermodynamics, with heat brought in by conduction and work done by pressure and viscous stress. In plane flow the momentum has an and a component, so there are four equations for four conserved quantities. This page studies laminar flow and adds no turbulence model. Stack the four conserved quantities per unit volume into one column:
守恒形式写成“守恒量的变化率加通量的散度等于源项”。通量分成两部分:上标 c 的对流(无黏)部分只依赖状态本身,与 Euler 方程相同;上标 v 的黏性部分由黏性应力和导热组成,还依赖速度和温度的梯度:
The conservation form reads: the rate of change of the conserved quantities plus the divergence of the flux equals the source. The flux has two parts: the convective (inviscid) part, superscript c, depends on the state alone and is the same as in the Euler equations; the viscous part, superscript v, comes from viscous stress and heat conduction and also depends on the gradients of velocity and temperature:
这里 、 各有 、 两列,乘以单位法向 就得到穿过法向为 的面的通量 、,下面三小节逐行写出。在任意一块固定区域 上积分,散度定理把散度的面积分变成边界上的法向通量。本页离散的是这个积分形式的定常情形:时间导数为零,每个控制体上净流出的通量等于体内的源项。
Here and each have an and a column; multiplying by a unit normal gives the fluxes and through a face of normal , written row by row in the next three subsections. Integrated over any fixed region , the divergence theorem turns the area integral of the divergence into the normal flux through the boundary. This page discretises the steady case of this integral form: the time derivative is zero, and on every control volume the net outward flux equals the source inside.
物理绕流中源项 只有一项可选的附加热源:能量方程中的 ,其幅值 是后面的设计参数之一,默认为 0;制造解算例中 另含一项解析源(见本节“本页的问题”)。第 2 节选定控制体,第 3 节在每个面上计算法向通量,第 5 节把它们组装成残差。
In the physical flow the source is only an optional added heating, the term of the energy equation, whose amplitude is one of the design parameters below (0 by default); in the manufactured case also contains an analytic source (see “The problem on this page” below). Section 2 chooses the control volumes, Section 3 computes the normal flux on each face and Section 5 assembles the fluxes into the residual.
图 1.1 画出本页的物理绕流问题:计算区域是物体(琥珀色,静止固壁)与截断的外边界之间的一圈;外边界左侧的蓝色实线是入口,右侧的绿色虚线是出口。箭头是本页用自己的代码在默认设置下解出的速度:流体从左侧流入,在物体前方减速,从两侧绕过物体,速度在壁面上为零。右侧列出设计变量 的当前值和每种边界给定的数据,下文逐项解释。
Figure 1.1 draws the physical-flow problem of this page. The domain is the ring between the body (amber, a wall at rest) and the truncated outer boundary; on the outer boundary the solid blue line on the left is the inlet and the green dashes on the right are the outlet. The arrows are the velocity this page's own code computes at the default settings: the fluid enters on the left, slows down in front of the body and passes it on both sides, and the velocity is zero at the wall. On the right are the current values of the design variables and the data each kind of boundary takes, explained below.
状态、状态方程与物性
The state, the equation of state and the transport properties
先把四个量的物理含义分开。 是质量密度, 是两个动量密度, 是总能量密度;这里 不是比内能。理想气体的比总能量由比内能与比动能组成。先减去动能,再除以 ,才得到温度:
Separate the four physical quantities first. is mass density, are momentum densities, and is total energy per volume; is not specific internal energy. Subtract specific kinetic energy from specific total energy, then divide by to recover temperature:
采用无量纲气体常数 1,。压力 ,这就是无量纲的理想气体状态方程,且 。声速为 ,温度为 1 的气体声速是 。
Use nondimensional gas constant one and , with : the ideal-gas equation of state in nondimensional form, with . The speed of sound is , so gas at temperature 1 has the speed of sound .
黏度 与导热系数 是气体的物性。本页可以取常物性,也可以取随温度变化的 Sutherland 律(参考温度 1、常数 0.4,并归一化使 时 );Prandtl 数 把导热系数与黏度联系起来:
The viscosity and the thermal conductivity are properties of the gas. The page takes either constant transport or Sutherland's law, which varies with temperature (reference temperature 1, constant 0.4, normalised so that at ); the Prandtl number ties the conductivity to the viscosity:
第 3 节的逐步推导对 Sutherland 律求导,并画出它在本页温度范围内的形状。
A step-by-step derivation in Section 3 differentiates Sutherland's law and plots it over this page's range of temperatures.
对流通量:质量、力和功率
The convective flux: mass, force and power
质量穿过单位面积面的速率是 ,其中 是法向速度;它携带的 x、y 动量分别乘以 。压力又在面上施加法向力 ,压力功率为 。因此能量对流项不是单独的 ,而是 :
Mass crosses unit face area at rate , where is the normal velocity, carrying x/y momentum at that rate times . Pressure contributes normal force and power , giving energy convection :
对流通量只含状态本身,不含导数;这四行正是二维 Euler 方程的通量。上标 c、v 分别代表对流和黏性。
The convective flux contains the state alone and no derivatives; these four rows are exactly the flux of the two-dimensional Euler equations. Superscripts c and v denote convection and viscosity.
黏性应力与热流
Viscous stress and heat flux
相邻流体层以不同速度运动时,黏性应力阻碍它们相对滑动;温度不均匀时,热量从热处流向冷处。Newton 流体的黏性应力与速度梯度成正比,平面运动中为
When neighbouring layers of fluid move at different speeds, viscous stress resists their sliding past each other; where the temperature is uneven, heat flows from hot to cold. In a Newtonian fluid the viscous stress is proportional to the velocity gradient; in plane motion
这里的 等表示空间导数;第 3 节在每个面上用重构得到的离散梯度 等替代它们。平面运动仍是三维流体本构的限制:,但通常 不为零。这解释了系数为何是 ,而不是把空间维数改成二就改成另一系数。 是面上的牵引(Cauchy 公式),既有法向分量,也有沿面的剪切分量。
Here and the like are spatial derivatives; Section 3 replaces them on every face by reconstructed discrete gradients and the like. Plane motion restricts a three-dimensional constitutive law: , but generally . This explains the factor : it does not change to another coefficient just because the space has two dimensions. is the traction on the face (Cauchy's formula), with a normal part and a shear part along the face.
导热服从 Fourier 定律 ,穿过面的法向热流为 。黏性应力做功为 ,导热携带能量而不携带质量。这给出黏性通量:
Heat conduction follows Fourier's law , and the normal heat flux through a face is . Viscous traction does work ; heat transports energy but no mass. This gives the viscous flux:
黏性功与热流都进入能量方程。面上的总通量为对流减黏性,热流在总能量通量中以正号出现。不能把黏性应力当成四个互不相关的标量扩散项:同一个张量 同时出现在两个动量行和能量行中。
Both viscous work and heat enter energy. Net flux is convection minus viscosity, so heat enters net energy with a plus sign. Viscous stress is not four independent scalar diffusion terms: the one tensor appears in both momentum rows and in the energy row.
图 1.2 在法向 的面上画出这些量的方向:牵引 的法向分量与剪切分量,以及能量行中的黏性功与热流。
Figure 1.2 draws their directions on a face of normal : the normal and shear parts of the traction , and the viscous work and heat flux of the energy row.
图 1.3 在本页物理绕流基准的一个实际面点上,把这两部分通量逐行逐项算出来。质量行没有黏性部分;两个动量行中压力项最大,这是低 Mach 数流动的特点;黏性部分要用到速度和温度的梯度,所以第 3 节必须先从存储的量重构出梯度。
Figure 1.3 evaluates both parts of the flux, row by row and term by term, at a real face point of this page's physical-flow baseline. The mass row has no viscous part; the pressure term is the largest in both momentum rows, as is typical at low Mach number; and the viscous part needs the gradients of velocity and temperature, which is why Section 3 must first reconstruct gradients from the stored values.
黏性改变了什么:无滑移、热壁与边界条件的个数
What viscosity changes: no slip, the thermal wall and the number of boundary conditions
没有黏性项时,方程只含一阶导数,就是 Euler 方程组。它是双曲型的:小扰动沿特征传播,法向速度为 时四个特征速度是 、、、。一段边界要给几个条件,等于从外部进入区域的特征有几条:亚声速入口 3 个,亚声速出口 1 个,固壁 1 个(只要求 ,流体可以沿壁面滑动)。
Without the viscous terms the equations contain only first derivatives: they are the Euler equations. They are hyperbolic: small disturbances travel along characteristics, and with normal velocity the four characteristic speeds are , , and . A boundary takes as many conditions as there are characteristics entering the domain through it: 3 at a subsonic inlet, 1 at a subsonic outlet and 1 at a wall (only ; the fluid may slide along the wall).
黏性项和导热项带来速度与温度的二阶导数,动量方程和能量方程于是像扩散方程一样:在每一段边界上,、、 都要给一个条件,给它本身,或给与之对应的通量(牵引、热流),每段边界 3 个。质量方程没有扩散项,仍是一阶的:密度只随流动输运,只在流体流入的边界上给定。所以二维可压缩 Navier–Stokes 方程是混合的双曲–抛物型方程组。本页的边界代码按这个个数闭合:
The viscous and conduction terms bring second derivatives of velocity and temperature, so the momentum and energy equations behave like diffusion equations: on every part of the boundary each of , and needs one condition, on the value itself or on the matching flux (the traction, the heat flux), three per boundary. The mass equation has no diffusion term and stays first order: density is only carried by the flow and is prescribed only where fluid enters. The two-dimensional compressible Navier–Stokes equations are therefore a mixed hyperbolic–parabolic system. This page's boundary code closes every boundary with that many conditions:
入口给 4 个:、、、 全部给定。出口给 3 个:总牵引 的两个分量和法向热流 ,密度和速度取内部的值。固壁给 3 个:无滑移 ,以及等温 或绝热 二者之一。图 1.4 用本页基准解的边界状态比较这两种计数。
The inlet takes 4: , , and are all prescribed. The outlet takes 3: the two components of the total traction and the normal heat flux , while the density and the velocity are taken from the interior. The wall takes 3: no slip, , and either an isothermal or an adiabatic . Figure 1.4 compares the two counts with the boundary states of this page's baseline.
无滑移条件要求流体在壁面上与壁面同速(本页固壁静止):切向速度也为零,这是它与 Euler 滑移壁的区别。壁面附近的速度因此从零逐渐增加,形成速度边界层;速度梯度在壁面上产生剪切应力,也就是摩擦阻力。能量方程在壁面上同样要一个条件:等温壁给定温度(本页默认 ,比来流的 略热,热量从壁面传入气体),绝热壁给定零热流。本页的“固壁热学”选项在两者之间切换;第 4 节把这些条件写进边界面的通量。
The no-slip condition makes the fluid at the wall move with the wall (at rest on this page): the tangential velocity vanishes too, which is what distinguishes it from the slip wall of the Euler equations. Near the wall the velocity therefore rises from zero across a velocity boundary layer, and the velocity gradient at the wall produces a shear stress, the skin friction. The energy equation also takes one condition at the wall: an isothermal wall prescribes the temperature (by default , slightly warmer than the incoming , so heat flows from the wall into the gas), an adiabatic wall prescribes zero heat flux. The page's “Wall thermal” control switches between the two; Section 4 writes these conditions into the boundary-face fluxes.
本页的问题:区域、边界数据、设计变量与基准解
The problem on this page: domain, boundary data, design variables and baseline
计算区域是物体与外边界之间的一圈(图 1.1)。物体是一个 8 边形,顶点到原点的距离在 0.28 到 0.80 之间,直径(最远两顶点的距离)为 1.23;外边界是半径 1.9 的正 8 边形。真实物体周围的流场延伸到无穷远,这里在离物体不远处截断,截断处用入口和出口条件代替。网格与二维标量页相同(第 2 节)。
The domain is the ring between the body and the outer boundary (Figure 1.1). The body is a polygon with 8 vertices at distances 0.28 to 0.80 from the origin, of diameter 1.23 (the largest distance between two vertices); the outer boundary is a regular polygon with 8 vertices at radius 1.9. The real flow around a body extends to infinity; here it is cut off not far from the body, and inlet and outlet conditions stand in for the far field. The mesh is that of the 2-D scalar pages (Section 2).
本页有两个算例。它们用同一组方程和同一张网格,却不是同一个边值问题。物理绕流(控件“层流绕流”):入口给定 、 和速度 ,其大小是 倍声速,所以 就是来流 Mach 数;出口给定 、;内圈是静止固壁,,等温壁温 (或绝热)。
The page has two cases. They share the equations and the mesh but are different boundary-value problems. Physical flow (the control “Laminar flow”): the inlet prescribes , and the velocity , whose size is times the speed of sound, so is the inflow Mach number; the outlet prescribes and ; the inner ring is a wall at rest, , with the wall temperature (or adiabatic).
制造解(默认算例):选一个光滑的解析场 (第 15 节给出公式),把它代入方程算出源项 ,于是 就是加了 的方程的精确解。两圈边界都按一个固定方向分成入口和出口,数据取自 ;内圈不是固壁。这个算例检查离散方程、导数和精度(第 15 节测量精度阶);其中 、 扰动入口速度, 是内边界入口温度的偏移。
Manufactured solution (the default case): choose a smooth analytic field (Section 15 gives the formulas), substitute it into the equations to get the source , and is then the exact solution of the equations with added. Both rings are split into inlet and outlet by one fixed direction, with data from ; the inner ring is not a wall. This case checks the discrete equations, the derivatives and the accuracy (Section 15 measures the order of accuracy); in it and perturb the inlet velocity and offsets the inlet temperature on the inner boundary.
按伴随优化文献的通常写法,设计变量记作 ,本页 。 是六个物理参数,顺序与代码相同:入口幅度(绕流中为 Mach 数,默认 0.12)、入口方向(默认 10°)、参考黏度 (0.2)、(0.72)、壁温 (绕流默认 1.05;制造解中默认为 1,内边界的入口温度取 )和附加热源的幅度 (0); 是全部 24 个网格顶点的 48 个坐标。合计 54 个设计分量,页首的全页路线图就从这些分量出发;第 12 节推导对它们的导数。
As is usual in the adjoint-optimization literature, the design variables are written ; here . are six physical parameters, in the code's order: the inlet amplitude (the Mach number in the flow case, 0.12 by default), the inlet direction (10°), the reference viscosity (0.2), (0.72), the wall temperature (1.05 in the flow case; 1 in the manufactured case, where the inner-boundary inlet temperature is ) and the amplitude of the added heating (0); holds the 48 coordinates of all 24 mesh vertices. That makes 54 design components, and the page roadmap at the top starts from them; Section 12 derives the derivatives with respect to them.
在默认设置下(二阶对流、常物性、等温壁、),本页用 Newton 法解出基准解(第 6 节);图 1.1 中的箭头就是物理绕流基准的速度。以来流速率 0.142、物体直径 1.23 和参考黏度计算,Reynolds 数约为 0.87:惯性力与黏性力同一量级,流动是定常层流。这张 24 个控制体的基网格很粗,壁面附近的梯度分辨不足;第 7 节给出加密网格上的同一流动。
At the default settings (second-order convection, constant transport, isothermal wall, ) the page computes the baseline solution by Newton's method (Section 6); the arrows of Figure 1.1 are the velocity of the physical-flow baseline. With the inflow speed 0.142, the body diameter 1.23 and the reference viscosity, the Reynolds number is about 0.87: inertial and viscous forces are of the same order, and the flow is steady and laminar. This base mesh of 24 control volumes is coarse and does not resolve the gradients near the wall; Section 7 shows the same flow on a refined mesh.
第 7 节定义目标函数:默认是温度跟踪 ,它是体积分,衡量温度与目标 的差;也可以选物体所受合力的 或 分量(压力加黏性牵引),或壁面热流 。本页要求的就是这些目标对 的导数。
Section 7 defines the objectives: by default the temperature tracking , a volume integral that measures the difference between the temperature and the target ; one may also choose the or component of the force on the body (pressure plus viscous traction) or the wall heat flux . What this page computes is the derivative of these objectives with respect to .
这组方程的作用
What these equations are for
在流体力学中,可压缩 Navier–Stokes 方程描述黏性与导热不能忽略的气体流动:物体表面的边界层与摩擦阻力,壁面与气体之间的换热,流动分离,以及尺度小或速度低、Reynolds 数不高的流动。它们是连续介质流动的基本方程;Euler 方程是略去黏性和导热后的特例。Reynolds 数很高时流动会变成湍流,直接解出所有尺度的涡代价太高,工程计算常改解时间平均的 RANS 方程:它的形式与本页的方程相同,只是黏度加上一个湍流黏度,另由湍流模型的输运方程决定这个黏度。
In fluid dynamics the compressible Navier–Stokes equations describe gas flows in which viscosity and heat conduction cannot be neglected: boundary layers and skin friction on bodies, heat transfer between a wall and the gas, flow separation, and small or slow flows at moderate Reynolds number. They are the basic equations of continuum flow; the Euler equations are the special case without viscosity and conduction. At high Reynolds number the flow becomes turbulent, resolving eddies of every size costs too much, and engineering computations usually solve the time-averaged RANS equations instead: they have the form of this page's equations with an eddy viscosity added to the viscosity, and a transport equation of a turbulence model determines that eddy viscosity.
在本课程中,这一页是从二维 Euler 到二维 RANS–SA 的中间一步。与二维 Euler 页相比,新的是黏性通量:面上除了两侧的状态,还要速度和温度的梯度,所以第 3 节用二次重构得到梯度,前向与伴随也要沿这条梯度链求导;固壁变成无滑移和热壁条件;目标函数多了黏性力和壁面热流。与一维层流 N–S 页相比,物理相同,但梯度成了二维向量,应力成了张量,面有任意方向的法向,网格坐标也成了设计变量。二维 RANS–SA 页在这些之上再加一个湍流模型方程。
In this course the page is the step between 2-D Euler and 2-D RANS–SA. Compared with the 2-D Euler page, what is new is the viscous flux: besides the states on its two sides, a face needs the gradients of velocity and temperature, so Section 3 obtains gradients by quadratic reconstruction, and the tangent and the adjoint must differentiate through this gradient chain too; the wall becomes a no-slip, thermal wall; and the objectives gain the viscous force and the wall heat flux. Compared with the 1-D laminar N–S page, the physics is the same, but the gradient becomes a two-dimensional vector, the stress a tensor, faces have normals in any direction, and the mesh coordinates become design variables. The 2-D RANS–SA page adds one turbulence-model equation on top of all this.
图 1.5 把本页放在网站的路线上:每往右一步主要增加一类新内容,每点的未知量从 1 个增加到 5 个;每一页的残差都由面循环组装,前向与伴随也沿同一个循环求导。点击方框进入该页的第 1 节。
Figure 1.5 places this page on the route through the site: each step to the right mainly adds one kind of ingredient, and the unknowns per point grow from 1 to 5; every page assembles its residual with a face loop, and the tangent and the adjoint differentiate the same loop. Click a box to open Section 1 of that page.
本页要回答的问题
The question this page answers
本页只回答一个问题:设计 有一个小变化时,目标 变化多少?这需要 54 个导数。前向(tangent)方法每个设计分量解一次线性方程,共 54 次;伴随方法只解一次转置方程。页首的全页路线图把后面各节串成这两条路线。
This page answers one question: how much does the objective change when the design changes a little? That takes 54 derivatives. The tangent (forward) method solves one linear system per design component, 54 in all; the adjoint method solves a single transposed system. The page roadmap at the top strings the later sections along these two routes.
阅读本页需要微积分、线性代数和数值方法(Newton 法与迭代线性求解)。自动微分与最优化不是前提:前向与伴随计算都从链式法则和矩阵转置讲起。
The page assumes calculus, linear algebra and numerical methods (Newton's method and iterative linear solvers). Automatic differentiation and optimisation are not prerequisites: the tangent and adjoint calculations start from the chain rule and matrix transposition.
要在计算机上回答这个问题,先要把连续方程变成有限个未知量的代数方程。第 2 节沿用二维标量页的网格,在每个控制体上存四个守恒量;第 3 节说明怎样在每个面上计算通量:对流部分用两侧的状态,黏性部分还要速度和温度的梯度。
To answer it on a computer, the continuous equations must first become algebraic equations in finitely many unknowns. Section 2 takes the mesh of the 2-D scalar pages and stores four conserved quantities in every control volume; Section 3 shows how the flux is computed on every face: the convective part from the states on the two sides, the viscous part also from the gradients of velocity and temperature.
网格:守恒量存在哪里
The mesh: where the conserved quantities live
先选一个状态,再选相邻面。说明哪些值存储在网格上,哪些量要到面上计算。
Select one state and an adjacent face. Identify stored quantities and quantities evaluated at the face.
第 1 节的方程在区域内每一点成立,计算机却只能存储有限个数。本节选定网格,决定未知量放在哪里(本页放在单元上:格心格式),并说明第 1 节的四个守恒量怎样存成数组、每个控制体怎样与相邻控制体共享面。
The equations of Section 1 hold at every point of the domain, but a computer stores finitely many numbers. This section chooses the mesh, decides where the unknowns live (on this page in the cells: the cell-centred scheme), and shows how the four conserved quantities of Section 1 are stored as arrays and how each control volume shares faces with its neighbours.
从已经认识的二维网格出发
Start with the familiar two-dimensional mesh
本页沿用二维标量的混合 O 型网格:24 个原始节点、8 个四边形和 16 个三角形。一个标量现在换成第 1 节的四个守恒量;第 1 节的两个算例都用这张网格。
The hybrid O-grid is the scalar lesson's mesh: 24 original vertices, eight quadrilaterals and sixteen triangles. Replace one scalar by the four conserved quantities of Section 1; both cases of Section 1 use this mesh.
本页把 定义为守恒量单元平均。基网格有 24 个控制体、48 个面、96 个状态自由度。数值重构中心使用面积质心,不是顶点的算术平均;二次重构还使用单元中心二阶矩。
Store conservative cell averages . The base mesh has 24 volumes, 48 faces and 96 unknowns. Reconstruction uses area centroids rather than vertex averages, and includes central second moments for quadratic reconstruction.
面法向从左控制体指向右控制体,边界指向流体域外。面长 与单位法向分开存储,求积中只乘一次面长。点击或选择面可查看左右编号、梯度邻域及实际状态;颜色和数值均来自当前求解。数组和编号从 0 开始。
Normals point from left to right volumes, or out of the fluid domain at a boundary. Store length separately from the unit normal and include length exactly once in quadrature. Select a face to inspect indices, reconstruction stencils and actual state values; colours and values come from the current solve. All arrays and indices start at zero.
从“每单位体积”到“一个控制体内有多少”
From density to the amount inside a control volume
第 1 节的四个守恒量都是单位体积内的量。本页采用单位厚度二维模型,因此控制体“体积”数值上等于平面面积。格心存储平均值时,控制体内四种总量就是 。格点版存储节点点值,围绕节点积分控制方程;不能把这个点值直接说成对偶体的精确平均值。
The four conserved quantities of Section 1 are amounts per unit volume. The planar model has unit depth, so volume equals area numerically. For stored cell averages, the four amounts in a cell are . The nodal scheme stores point values and integrates equations around a vertex; a nodal value is not the exact average over its dual volume.
阅读后文时区分三种符号: 是介绍物理模型时的连续函数; 是四分量存储向量; 是长度 的有限维列向量。本页每个符号只有一种含义;下表列出最容易混淆的几个。
Distinguish the continuous field , the four-component stored vector , and the length- column . Each symbol on this page has one meaning; the table lists those most easily confused.
| 符号Symbol | 含义Meaning |
|---|---|
| ,, | 温度;上标 只表示温度, 是温度的离散梯度temperature; a superscript always means temperature, and is the discrete temperature gradient |
| 转置transpose | |
| 两个速度分量the two velocity components | |
| :,,,: , , , | 前向(tangent)导数:离散程序变量 沿所选方向的一阶导数,即自动微分的点号;状态方向 是 tangent 的种子或解出的响应。点号不是时间导数forward-mode (tangent) derivative: the first derivative of the discrete program variable along the chosen direction, the dot of algorithmic differentiation; the state direction is a tangent seed or a solved response. The dot is not a time derivative |
| :,,: , , | 反向(adjoint)导数: 的反向权重,按逆序用 累加; 是给残差行的种子。上横线不表示平均值reverse-mode (adjoint) derivative: the reverse weight of , accumulated with in reverse order; seeds the residual rows. The bar never means an average |
x_d,x_bx_d, x_b | 代码与播放器中 与 的写法(Tapenade 约定),如 residual_d、residual_bhow code and players write and (the Tapenade convention), e.g. residual_d, residual_b |
| 仅附录 A:连续函数的变分(连续伴随推导)Appendix A only: the variation of a continuous function (continuous-adjoint derivation) | |
| Newton 或线性求解的增量; 等只是差值a Newton or linear-solver increment; and the like are plain differences | |
| ,,, , | 设计变量:物理参数 与节点坐标 ; 是设计方向(tangent 种子), 是设计伴随,即梯度the design variables: physical parameters and node coordinates ; is the design direction (tangent seed) and the design adjoint, the gradient |
| ,, | 离散残差对状态与设计的 Jacobian;分开处理参数与坐标时写出 的两部分 、,目标的 与梯度 同样分块Jacobians of the discrete residual with respect to state and design; where parameters and coordinates are treated separately, the two parts of give and , and the objective's and the gradient split the same way |
| 等 etc. | 原始量离散梯度的分量(此处上标是变量名,下标是方向)components of discrete primitive gradients (superscript: variable; subscript: direction) |
| 压力;观测阶记为 ord,A.14 的多项式次数记为 pressure; observed order is written ord and the A.14 polynomial degree | |
| 面通量 的反向种子:屏蔽后的左右残差种子之差 reverse seed of the face flux : the difference of the masked left and right residual seeds | |
| 格心中心二阶矩central second moments of a cell | |
| ,, | 强边界行掩码;边界惩罚长度strong-row mask; boundary penalty length |
| ,, | 权重(求积权重 、拟合权重 );松弛或步长因子weights (quadrature , fit ); relaxation or step factor |
| ,,,, , , | 仅附录 A:连续乘子;热流目标权重;A.16 加密指标;A.17 端点乘子Appendix A only: continuous multipliers; heat-objective weight; A.16 refinement indicator; A.17 endpoint multipliers |
网格、控制体和存储的数组定好以后,第 3 节在每个面上计算第 1 节的通量:对流部分只要两侧的状态,黏性部分还要速度和温度的梯度,它们都由存储的量重构得到。
With the mesh, the control volumes and the stored arrays in place, Section 3 computes the flux of Section 1 on every face: the convective part needs only the states on the two sides, the viscous part also the gradients of velocity and temperature, all reconstructed from the stored values.
面上的通量:重构与黏性梯度
The flux on a face: reconstruction and viscous gradients
选择内部面,依次查看两个求积点。先核对左右迹,再核对应力和净通量。
Select an interior face and visit both Gauss points. Check traces, stress and net flux in that order.
第 2 节把四个守恒量存在每个控制体上,通量却要在控制体之间的面上计算。本节从存储的量出发,得到面上的状态和梯度:先写出面上的黏性通量要用的离散梯度,再用加权最小二乘重构得到两侧的迹和梯度,最后得到一个面的净通量。
Section 2 stores the four conserved quantities in every control volume, but the flux is computed on the faces between control volumes. This section starts from the stored values and obtains the states and gradients at a face: first the discrete gradients the viscous flux on a face uses, then the weighted least-squares reconstruction that gives the traces and gradients on the two sides, and finally the net flux of a face.
面上的黏性通量用离散梯度计算
The viscous flux on a face uses discrete gradients
有了网格和存储的量,下面在每个面的求积点上计算第 1 节的通量 。对流部分只要两侧的状态;黏性部分中的 等空间导数,代码用本节后面由重构得到的离散梯度 等替代:
With the mesh and the stored values in place, the flux of Section 1 is evaluated at the quadrature points of every face. The convective part needs only the states on the two sides; in the viscous part the code replaces the spatial derivatives and the like by the discrete gradients and the like, obtained later in this section by reconstruction:
这里 专指下面定义的离散梯度(上标是变量名,下标是方向)。它们与第 1 节的 、 是同一组公式,只是导数换成了离散梯度;物性 、 在面状态的温度上取值。
Here denotes the discrete gradients defined below (superscript: variable; subscript: direction). These are the formulas for and of Section 1 with the derivatives replaced by discrete gradients; the properties and are evaluated at the temperature of the face state.
同时保留平均值与几何矩
Retain averages and geometric moments
格心中心矩为 等,保证 。用邻居的几何矩计算 ,而不是把平均值硬当成中心点值。
For cells the moments are and similarly for the others, ensuring . Neighbour equations use their geometric moments, not cell averages misinterpreted as centre point values.
邻域规则是连通图上的逐圈扩展:从 出发,每次把当前一圈的全部面邻居加进来,直到连同自身至少有 12 个控制体(最多 5 圈)才停止。因为整圈加入,总数可能超过 12。基网格上,格心 0 用到其余 23 个格心中的 15 个;全部格心的邻居数在 11 到 15 之间。因此至少有 11 个邻居差值,多于待定的 5 个系数,加权最小二乘是超定的;四个守恒分量共享同一几何矩阵。
The stencil grows ring by ring on the face-neighbour graph: starting from , add every neighbour of the current ring until, counting itself, at least 12 control volumes are collected (at most five rings). Whole rings are added, so the count can exceed 12. On the base mesh, cell 0 uses 15 of the other 23; every stencil has between 11 and 15 neighbours. Hence at least 11 neighbour differences constrain the five coefficients, and the weighted least-squares fit is overdetermined; the four conserved components share one geometric matrix.
二次多项式用于黏性迹和梯度。对流一阶使用存储状态,二阶使用 ;无 limiter。
Quadratic polynomials provide viscous traces and gradients. First-order convection uses stored states, while second order uses . No limiter is applied.
这一对流通量是 Rusanov(局部 Lax–Friedrichs)型:两侧物理通量的平均,减去“耗散速度 × 跳跃”的一半;这里耗散速度取常数 3,所以系数是 。它不逐面取 ,因此残差处处可微,复数步长也保持解析;代价是只在 的范围内保证足够的耗散,第 6 节的诊断 maxWave 必须小于 3。默认基准的最大特征速度为 ,约为 3 的 49%。同一系列的取值不同:二维 Euler 页固定取 2;一维 N–S 页取两侧平均 ,因为一维管道中处处 ,这个状态相关的平均本身光滑。二维面法向可指向任意方向, 会变号,状态相关的 在 处不可微,所以本页与二维 Euler 页都取常数。上面的基准只用到 3 的约一半,常数 2 也能覆盖它,但余量更小;本页取 3。
This convective flux is of Rusanov (local Lax–Friedrichs) type: the average of the two physical fluxes minus half of “dissipation speed × jump”. The dissipation speed is the constant 3, hence the coefficient . It does not take face by face, so the residual stays differentiable everywhere and complex steps stay analytic; in exchange, enough dissipation is guaranteed only while , and the Section 6 diagnostic maxWave must stay below 3. The default baseline's largest characteristic speed is , about 49% of 3. The series uses different values: the 2D Euler page fixes 2; the 1D N–S page uses the two-sided average , which is smooth because throughout the 1D duct. In 2D a face normal can point anywhere, changes sign and a state-dependent is not differentiable at ; this page and the 2D Euler page therefore use constants. The baseline above uses about half of 3; the constant 2 would also cover it, with less margin, and this page takes 3.
最小二乘正规方程怎样得到
Deriving the least-squares normal equations
先只看一个守恒分量 ,例如密度。令五个未知系数排成列 ,邻居中心相对位置为 。因为 是平均值,格心的拟合行还必须包含邻居和本单元的中心矩:
Consider one conserved component , such as density. Stack its five coefficients into and let be the neighbour-centre offset. Cell-average fitting must include both cells’ central moments:
格点版本令全部中心矩为零。把所有 叠成矩阵 ,把 叠成 ,权重构成对角矩阵 。最小化的是“邻居数据与多项式预测不一致”的加权平方和:
For nodes all central moments are zero. Stack rows into , differences into , and weights into diagonal . Minimize the weighted squared mismatch between data and polynomial predictions:
这是一个 系统,每个守恒分量各解一次,几何矩阵可以共享。邻居数量多于五仍不保证可解,还要有足够独立的几何方向。实现检查权重的有限性和二次多项式再现误差;这里写逆矩阵是为了说明关系,不要求读者把“大矩阵显式求逆”当作求解原则。拟合矩阵 与面通量 由下标和维度区分。
This is a system for each conserved component, sharing geometry. More than five neighbours is insufficient without independent geometric directions. The implementation checks finite weights and quadratic reproduction. Inverse notation expresses the relation, not a recommendation to explicitly invert large matrices. Fit matrix and face-flux vector differ in indices and dimensions.
黏性迹、梯度与跳跃修正
Viscous traces, gradients and jump correction
。先对守恒多项式求空间梯度,再用原始变量转换的 Jacobian 得到各侧原始量梯度。跳跃修正使用同一面点的两侧迹,系数为 2;它不是一维的中心差分。精确二次守恒多项式的左右迹一致,修正为零。非线性转换、边界和通量装配后的整体阶数仍需制造解测量。
For , differentiate the conservative polynomial spatially, then apply the primitive-conversion Jacobian. The penalty uses both traces at the same face point with coefficient two; it is not the 1D centre difference. Exact quadratic conservative traces agree and have zero penalty. Nonlinear conversion, boundaries and flux assembly still require measured global convergence.
为什么需要跳跃项?只平均两侧梯度时,中心差分形式的单元梯度看不到一维奇偶(棋盘)模式 :每个单元的梯度都是零,黏性通量也为零,这个模式既不受阻尼,也不被察觉。把跳跃项系数记作 (本页 ),在均匀一维网格、线性重构且面位于中点时,面梯度化为 ,其中 是两侧单元梯度。对 ,扩散算子的符号乘以 为
Why add a jump term? Averaging the two cell gradients alone cannot see the 1D odd-even (checkerboard) mode : every centred cell gradient vanishes, so the viscous flux vanishes and the mode is neither damped nor detected. Write the jump coefficient as ( here). On a uniform 1D grid with linear reconstruction and the face at the midpoint, the face gradient becomes , with the cell gradients. For , times the diffusion symbol is
最短波 的值依次为: 时为 (奇偶解耦); 时为 (紧致两点差分); 时为 ,并使 修正项的系数 等于 ,在均匀网格上达到四阶; 时为 ,最短波阻尼是紧致差分的两倍,光滑波的修正系数为 ,仍是二阶相容。可见任何 都相容,差别只在有限网格的阻尼与截断误差;本页取 2,是偏向强阻尼的选择,不是由方程唯一决定的常数。
At the shortest wave the values are: gives (odd-even decoupling); gives (compact two-point difference); gives and makes the coefficient equal , fourth order on a uniform grid; gives , twice the damping of the compact difference, while the smooth-wave coefficient keeps second-order consistency. Any is consistent; the choice only changes finite-mesh damping and truncation error. This page uses 2, favouring strong damping; it is not a constant fixed by the equations.
守恒量梯度怎样转换成速度、温度梯度
Converting conserved gradients to velocity and temperature gradients
对 使用商法则,再对总能量关系使用链式法则。若 表示重构守恒分量 的多项式在方向 上的斜率,实际计算为:
Apply the quotient rule to and the chain rule to total energy. With the slope, in direction , of the reconstructed polynomial of conserved component , code evaluates:
不要用“分别拟合速度和温度”替代这条链:它会定义另一种离散残差。本页先拟合守恒量,再进行上述非线性转换。二阶对流仅取多项式的常数与线性部分,黏性仍使用完整二次迹及其梯度;空间精度按钮不是将所有多项式统一切成同一次数。
Fitting velocity and temperature independently would define a different residual. This implementation reconstructs conserved variables and then converts them nonlinearly. Second-order convection keeps only the constant and linear terms, while viscosity retains quadratic traces and gradients; the spatial-order control does not cut every polynomial to one degree.
逐步推导:原始变量、Jacobian 行与梯度转换
Step by step: primitive variables, Jacobian rows and the gradient conversion
面上的每个物理量都从守恒量 的迹算出。前向、反向都要反复用到它们对 的导数,所以先把这组导数一次推导完整,后面各节直接引用。下面的行向量都是 ,列的顺序与代码的四个分量槽一致。
Every physical quantity on a face is computed from traces of the conserved state . The tangent and the reverse pass use their derivatives with respect to again and again, so derive them once, completely; later sections refer back here. All rows below are , ordered like the four component slots in the code.
第一步,写出原始变量(physics.js 的 prim())。 是单位质量总能:
Step 1, write the primitive variables (prim() in physics.js). is total energy per unit mass:
第二步,逐个求方向导数(点号)。商法则给出 、;再把它们代入 并按 归并系数:
Step 2, differentiate each one along a direction (the dot). The quotient rule gives and ; substitute them into and collect the coefficients of :
压力是乘积 ,所以 ,其中 。把 代入第一个分量, 恰好消去:
Pressure is the product , so with . Substituting into the first entry cancels :
第三步,用链式法则得到空间梯度。迹 在点 的斜率 是一个四分量列向量;对 求 ,只需把上一步的行乘上这个斜率(physics.js 的 gradient()):
Step 3, obtain spatial gradients by the chain rule. The slope of a trace at is a four-component column; differentiating with respect to multiplies the row of Step 2 by this slope (gradient() in physics.js):
例如温度:把 乘开,再按上文的写法重新分组,
For temperature, multiply out and regroup as above:
正是代码中 的公式。所以“守恒量斜率 → 原始量梯度”只是一个 行乘法。
exactly the code's formula for . Converting a conservative slope into a primitive gradient is therefore one row product.
第四步,对梯度也求方向导数。 通过两个位置依赖状态:行 本身和斜率 。乘积法则给出两项,第二项需要对行再求导:
Step 4, differentiate the gradient along the direction as well. depends on the state in two places: through the row and through the slope . The product rule gives two terms; the second one differentiates the row again:
三个二阶行写成列(即它们的转置,反向时正好用到转置):
The three second-derivative rows, written as columns (their transposes, which is what the reverse pass uses):
把 的这一行乘上 ,得到 ,即第 8 节写出的 中与 有关的部分。反向时这两项变成两条路径:,以及 。
Multiplying the row for by gives , the -dependent part of the that Section 8 writes out. In reverse the two terms become two paths: and .
核对:在物理绕流基准的一个实际 Gauss 点(面 0),第二步的五行与第四步的六行( 两个斜率方向)与 physics.js 的 prim() 和 gradient() 经过运算图求导的结果相比,最大相对差分别为 和 (本页代码在构建时计算)
Check: at a real Gauss point of the physical-flow baseline (face 0), the five rows of Step 2 and the six rows of Step 4 (slopes in and ) agree with the graph derivatives of prim() and gradient() in physics.js to a maximum relative difference of and (computed by this page's code at build time)
逐步推导:从三维 Newton 应力到面黏性通量及其 Jacobian
Step by step: from the 3-D Newtonian stress to the viscous face flux and its Jacobian
第一步,从三维本构出发。速度为 ,且任何量都不随 变化,所以速度梯度矩阵的第三行和第三列为零,:
Step 1, start from the 3-D constitutive law. The velocity is and nothing varies with , so the third row and column of the velocity-gradient matrix vanish and :
一般不为零,但它不作用在任何法向位于 平面内的面上,所以只剩下三个分量。离散时把 换成面梯度 ,依此类推(physics.js 的 viscous())。
is generally nonzero, but it acts on no face whose normal lies in the plane, so three components remain. The discrete code replaces by the face gradient , and so on (viscous() in physics.js).
第二步,用 Cauchy 公式求面上的牵引,并加上导热:
Step 2, form the traction on a face with Cauchy's formula and add conduction:
图 3.2 画出了物理绕流基准中一个实际面点的这些量。
Figure 3.2 draws these quantities at a real face point of the physical-flow baseline.
第三步,对六个面梯度 线性化。把第一步的应力代入第二步,每个动量行都是 的线性函数;能量行等于 乘第二行、 乘第三行,再加上 对 的导数 :
Step 3, linearise in the six face gradients . Substituting Step 1 into Step 2 makes each momentum row linear in ; the energy row is times row two plus times row three, plus the derivative of with respect to :
第四步,对其余输入求导:面速度只出现在黏性功中; 让三个应力分量整体按比例变化; 只出现在热流中;法向同时出现在牵引和热流中。
Step 4, differentiate with respect to the remaining inputs: the face velocity appears only in viscous work; scales all three stresses; appears only in the heat flux; the normal appears in both traction and heat flux.
在 固定时计算; 通过 另有一条路径,见下一步。法向导数最后还要乘上第 12 节的 。
holds fixed; has its own path through , see the next step. The normal derivative is finally multiplied by from Section 12.
第五步,对物性求导。Sutherland 律取参考温度 1、常数 0.4,归一化为 (常物性时 ):
Step 5, differentiate the transport law. Sutherland's law with reference temperature 1 and constant 0.4, normalised so that ( under constant transport):
取对数再求导最简单:。于是
Differentiating the logarithm is easiest: , hence
图 3.3 在本页实际出现的温度范围内画出 与它在 的切线。常物性模式下 ,但对 与 的导数仍然存在。
Figure 3.3 plots and its tangent at over the temperatures that actually occur on this page. In constant-transport mode , but the derivatives with respect to and remain.
核对:把第三、四步合成 矩阵(六个梯度、两个速度、两个法向分量、、),与 physics.js 的 viscous() 在同一 Gauss 点的运算图导数相比,最大相对差为 ;第五步的 与 transport() 的运算图导数相比为 (本页代码在构建时计算)
Check: the matrix of Steps 3 and 4 (six gradients, two velocities, two normal components, , ) agrees with the graph derivative of viscous() in physics.js at the same Gauss point to a maximum relative difference of ; of Step 5 agrees with the graph derivative of transport() to (computed by this page's code at build time)
一个面的通量读取哪些状态
Which states one face flux reads
面通量用到两侧的二次迹、它们的斜率和二阶对流的线性迹。每一项都来自拟合系数 ,而 含有邻域 中每个状态与 之差。所以内部面 的通量读取集合
A face flux uses the quadratic traces on both sides, their slopes and the linear convective traces. Each comes from fit coefficients , where holds the difference between and every state of the stencil . The interior face therefore reads the set
边界面只有左侧:。
A boundary face has a left side only: .
基网格上每个面读取 15–19 个状态(本页代码在构建时统计)。图 3.4 画出按上文逐圈规则得到的单元 0 的邻域,以及附录 B.4 逐项跟踪的面 0 的读取集合;反向时,这个面的种子恰好写回同一组状态。
On the base mesh each face reads 15–19 states (counted by this page's code at build time). Figure 3.4 shows the stencil of cell 0 given by the ring-by-ring rule above, and the read set of face 0, the face that Appendix B.4 follows number by number; in reverse, the seed of that face writes back into exactly the same states.
内部面的通量由两侧的重构迹和面梯度算出,每个面只算一次,分给两侧的控制体。边界面只有内侧一个控制体:外侧的状态、牵引或热流要由边界条件给出,个数就是第 1 节数过的入口 4 个、出口 3 个、固壁 3 个。第 4 节把这些数据代入边界面的通量。
Every interior face gets its flux from the reconstructed traces and face gradients on its two sides, computed once and shared by the two control volumes. A boundary face has a control volume on its inner side only: the state, the traction or the heat flux outside must come from the boundary conditions, as many as Section 1 counted: 4 at the inlet, 3 at the outlet and 3 at the wall. Section 4 substitutes these data into the boundary-face fluxes.
完整边界闭合
Complete boundary closures
分别选择入流和牵引出口。切换到绕流后,再比较等温与绝热壁面;每次先重新计算。
Compare inflow and traction outflow. In the physical-flow case, recompute and compare isothermal and adiabatic walls.
先定义边界函数,再对边界函数求导
Define a boundary function before differentiating it
制造解在内外边界按固定入流/出流分区提供数据;它的内圈不是固壁。第 1 节的两个算例中,只有绕流算例的内圈是静止无滑移壁。分区由基网格面法向与固定方向 的点积决定,求导时保持不变;参数或几何扰动越过支持范围需要报告,不能默默切换分支。
Manufactured data use fixed inflow/outflow partitions on both boundaries; the inner boundary is not a solid wall in that case. Of the two cases of Section 1, only the flow case has a stationary no-slip inner wall. The base normal dotted with fixed direction sets the partition, held fixed during differentiation. Perturbations outside the supported branch must be reported, not silently switched.
入口给定密度、两个速度和温度。对流使用规定状态的物理通量;速度、温度的黏性梯度使用上式弱边界修正。几何距离和边界数据都要参与求导。这里没有把同一模板无条件宣称为任意网格上的二阶壁面应力。
Inflow prescribes density, both velocities and temperature. Convection uses the prescribed physical flux, while viscous gradients of velocity and temperature receive the weak boundary correction above. Geometry distances and prescribed data are differentiated. This stencil is not unconditionally advertised as second-order wall stress on arbitrary meshes.
出口采用总牵引与热流
Outflow specifies total traction and heat flux
绕流外出口取 ;制造解取解析场的总牵引和热流。出口通量中的压力与应力已按条件消去,不能再叠加一次内部黏性通量。它是有限截断域的边界闭合,不是未经证明的无限远条件。
Flow outflow uses ; manufactured outflow uses analytic traction and heat flux. Pressure and stress have already been eliminated in this net boundary flux: do not subtract an interior viscous flux again. This defines a truncated-domain closure, not a proven infinite-far-field condition.
固壁的机械与热学条件
Mechanical and thermal wall conditions
热学条件二选一:等温壁 ,绝热壁 。等温壁把壁温送入物性和梯度修正;绝热壁显式令法向热流为零。壁面密度从内部二次迹外推,压力由该密度和面温度得到。因为壁面速度为零,黏性功在壁面净能量通量中为零。
The thermal condition is one of two: at an isothermal wall, at an adiabatic one. An isothermal wall sends wall temperature into transport and gradient correction; an adiabatic wall sets normal heat flux to zero. Extrapolate density from the interior quadratic trace and form pressure with face temperature. Zero wall velocity eliminates wall viscous work from net energy flux.
格心版没有边界存储点,所有条件通过面通量和梯度修正进入残差,不覆盖任何残差行。
Cells have no stored boundary points. Conditions enter through face fluxes and gradient corrections, with no residual-row replacement.
把边界条件代入通量,而不只是写在图上
Substitute boundary data into the actual flux
出口给定的是总牵引 ,不是同时各自给定压力和所有速度导数。将这个等式代入动量通量, 就整体替换为 ;能量中同样合并压力功与黏性功:
Outflow specifies total traction , not pressure and all velocity derivatives independently. Substitute it into momentum flux. Combine pressure and viscous work in energy in the same way:
固壁处速度为零,所以质量通量为零、压力功为零、黏性功也为零,只剩法向导热通量进入能量收支。等温边界规定温度值;绝热边界规定法向热流。两者不是同时施加的两个条件。
At a stationary no-slip wall, mass flux, pressure work and viscous work vanish, leaving normal heat in the energy balance. Isothermal data specify temperature; adiabatic data specify normal heat flux. They are alternative conditions.
以一个原始变量 为例,弱梯度修正中的距离、法向、内侧迹和边界值都可能变化。把 记清楚,就可以用乘积与商法则逐项求导:
For one primitive variable , boundary distance, normal, interior trace and prescribed value may all vary. Writing , product and quotient rules give every term:
上式的 是边界距离, 是它的方向导数。若是规定不变的壁温,其状态方向导数为零,但“对壁温参数求导”时 不为零。
Here is the boundary distance and its tangent. A fixed prescribed temperature has zero state derivative, but a wall-temperature design direction has nonzero .
边界沿用同一个系数 2。在一维壁面,设单元中心到壁面的距离为 ,线性重构的内侧迹为 ,修正后的法向梯度化为 :两点差分的两倍减去重构梯度,对线性分布精确。惩罚项 把内侧迹拉向规定值,这就是弱施加(Nitsche 型):系数越大越接近强施加,但它只改变有限网格误差,不改变相容性。格心的 是单元中心到该面求积点的法向距离,随网格一起按 缩小。
The boundary reuses the coefficient 2. At a 1D wall, let be the distance from the cell centre to the wall; the linear trace is and the corrected normal gradient becomes : twice the two-point difference minus the reconstructed gradient, exact for linear profiles. The penalty pulls the interior trace towards the prescribed value. This is weak (Nitsche-type) imposition: a larger coefficient approaches strong imposition but only changes finite-mesh error, not consistency. For cells is the normal distance from the centroid to the face Gauss point and shrinks with .
图 4.2 标出本页每个边界面采用的闭合:入流、出流由固定方向 与面法向的点积划分,内圈在物理绕流中是无滑移固壁。
Figure 4.2 marks the closure used on every boundary face: inflow and outflow are split by the dot product of the face normal with the fixed direction , and the inner ring is a no-slip wall in the physical flow.
逐步推导:固壁 Gauss 点的线性化与转置
Step by step: linearising and transposing a wall Gauss point
第一步,写出点通量的值(core.js 面循环中的固壁分支)。固壁只用左侧的二次迹 及其斜率:
Step 1, write the value of the point flux (the wall branch of the face loop in core.js). A wall point uses only the left quadratic trace and its slopes:
罚项里的规定值是 ;温度只在等温壁上修正,。
The prescribed values in the penalty are ; temperature is corrected only at an isothermal wall, with .
其中 ,即第 3 节应力公式里的矩阵,。绝热壁不修正温度梯度,并把能量分量直接置零()。无滑移使黏性功 消失,所以能量行只剩热流。
Here is the matrix of the stress formulas of Section 3, so . An adiabatic wall leaves the temperature gradient uncorrected and sets the energy entry to zero (). No slip removes the viscous work , so only the heat flux remains in energy.
第二步,对状态方向线性化。规定值 不随状态变化,所以罚项只通过内侧迹 变化:
Step 2, linearise along a state direction. The prescribed does not change with the state, so the penalty varies only through the interior trace :
等温壁 固定,因此 :物性在等温壁上没有状态导数,只有对 的设计导数。绝热壁 ,。 与 用第 3 节的行与二阶行。
At an isothermal wall is fixed, so : transport has no state derivative there, only a design derivative with respect to . At an adiabatic wall and . and use the rows and second rows of Section 3.
第三步,转置。边界面只有左侧,面种子为 ,点通量的权重为 。把第一步每一项的系数反过来收集:
Step 3, transpose. A boundary face has only a left side, so the face seed is and the point-flux weight is . Collect the coefficient of each term of Step 1 in reverse:
、 由 与 按第 9 节的应力和热流反传得到。 加到 。规定值也在罚项里: 是常数,只有等温壁上的 是设计变量,所以等温壁上 ,其中 ;常物性时罚项是 进入能量行的唯一路径(第 9 节逐条反向语句的核对包含这一项)。绝热壁上 加到 。最后 经第 3 节的行和第 9 节的拟合转置回到状态。
and come from and through the stress and heat reverse of Section 9. is added to . The prescribed values sit in the penalty too: are constants, and only at an isothermal wall is a design variable, so at an isothermal wall with ; under constant transport the penalty is the only path from into the energy row (the check of the statement-by-statement reverse in Section 9 includes it). At an adiabatic wall is added to . Finally return to the states through the rows of Section 3 and the fit transpose of Section 9.
格心版本没有强边界行():固壁完全通过上面的点通量进入残差,第三步就是它的全部转置。
The cell-centred version has no strong rows (): the wall enters the residual only through the point flux above, and Step 3 is its complete transpose.
核对:用这些公式(连同第 8、9 节的内部面)组装的 与 ,在物理绕流基准的等温与绝热固壁上,与本页运算图的 JVP、VJP 相比最大相对差为 和 (本页代码在构建时计算)
Check: and assembled from these formulas (with the interior faces of Sections 8 and 9) agree with the page's graph JVP and VJP on the physical-flow baseline, with isothermal and adiabatic walls, to a maximum relative difference of and (computed by this page's code at build time)
离散残差与执行过程
Discrete residual and execution
跳到内部面,单步经过左加、右减。观察四个分量中的任一个如何逐步累加。
Jump to an interior face and step through add-left/subtract-right. Follow accumulation in any of the four components.
图 5.1:守恒分发与种子差Figure 5.1: Conservative scatter and seed difference
固定网格拓扑后的有限维函数
A finite-dimensional function at fixed topology
向左控制体加通量、向右减通量。源项用每个多边形的三角剖分与七点三角求积,包含物理位置与权重。所有残差都是积分平衡量,不除以体积。先求梯度再逐面装配,不能在处理某个面时就覆盖邻居的梯度。
The incidence adds flux to the left and subtracts it from the right. Sources use polygon triangulation and seven-point triangle quadrature, including physical locations and weights. Residuals are integrated balances without volume division. Compute gradients before the face loop rather than overwriting neighbour gradients during assembly.
geometry = areas, centroids, moments, subfaces, quadrature
coeff = weighted_quadratic_fit(conservative_states, geometry)
Rraw = 0
for face in topology:
H = 0
for segment and Gauss point:
traces, gradients = reconstruct_and_close_boundary(...)
H += weight * (convective_flux - viscous_flux)
Rraw[L] += H; if R >= 0: Rraw[R] -= H
Rraw -= integrated_sources
R = replace_boundary_rows_if_node(Rraw)下面回放真实面计算和装配事件,局部表逐个显示该面求积点的数据,面通量表显示所有子面与求积点的和。它不是把完整标量运算图逐节点动画化;第 8、9 节提供这些事件内部的链式法则。末步误差比较回放数组和完整运算。
The player replays actual face computations and assembly events. Local quantities follow each quadrature point, while face fluxes sum every segment and point. This is not an animation of every scalar graph node; Sections 8 and 9 derive the internal chain rule. The final replay array is checked against the full operation.
从积分守恒定律逐步得到残差
Deriving the residual from integral conservation
对静止控制体写“积累率+净流出=体源”。对流减黏性的符号沿用第 1 节;散度定理把面积上的散度积分变成边界积分:
For a stationary volume write “accumulation + net outward flux = source.” With the convection-minus-viscosity convention of Section 1, the divergence theorem converts area divergence into boundary flux:
每个内部面只有一个四分量 。左单元加它、右单元减它,因而求和时严格抵消。这是离散守恒最直接的检查,与是否使用一阶或二阶重构无关:
Each interior face has one four-component , added left and subtracted right. It cancels in the global sum, regardless of reconstruction order:
一个直子面的两个 Gauss 点为中点加减切向位移,每个权重都是半个面长。弯折对偶面分别对子面应用此公式。先对点求通量,后乘权重,不能把单位法向中的归一化面长又乘进去一次:
The two Gauss points on a straight segment are symmetric about its midpoint, with half-length weights. Apply this separately to bent dual segments. Evaluate point flux, then multiply its weight exactly once:
最后才覆盖格点强边界行。掩码矩阵 的保留行对角为 1、覆盖行对角为 0; 把边界条件的数值填到指定位置。格心版 ,没有此覆盖步骤。
Strong nodal rows are replaced last. Diagonal mask is one for retained rows and zero for replaced rows; inserts constraints at their assigned positions. For cells and there is no replacement.
逐步推导:为什么每段两个 Gauss 点、每个三角形七个点就够了
Step by step: why two Gauss points per segment and seven per triangle suffice
图 5.2 画出一个控制体实际使用的求积点。
Figure 5.2 shows the quadrature points one control volume actually uses.
第一步,推导两点 Gauss 公式。把线段写成 ,。要求 对 成立:
Step 1, derive the two-point Gauss rule. Write the segment as with , and require for :
由第二、四式得 、;再由第一、三式,
The second and fourth conditions give and ; the first and third then give
乘上 ,得到上文所用的求积点 和权重 。它对沿线段的三次多项式精确;通量本身不是多项式,所以面通量仍有求积误差。
Multiplying by gives the points and weights used above. The rule is exact for cubics along the segment; the flux itself is not a polynomial, so face fluxes still carry quadrature error.
第二步,体积分。多边形从第一个顶点剖分成扇形三角形,每个三角形用 7 点 5 次精确公式(geometry.js 的 bary 表)。面积、形心和二阶矩的被积函数 都不超过 2 次,因此它们是精确值:
Step 2, volume integrals. The polygon is split into a fan of triangles from its first vertex; each triangle uses a 7-point rule exact to degree 5 (the bary table in geometry.js). Area, centroid and second moments integrate , all of degree at most 2, so they are exact:
这里 取遍所有扇形三角形上的七个点;每个三角形上七个权重之和等于它的面积。
Here runs over the seven points of every fan triangle; on each triangle the seven weights add up to its area.
第三步,得到平均值守恒。形心处 ,二次项减去的正是中心矩,所以重构多项式在本单元上的平均值精确等于存储值:
Step 3, conservation of the mean follows. About the centroid , and the quadratic terms subtract exactly the central moments, so the reconstruction averages to the stored value:
核对:对全部 24 个单元,7 点求积得到的面积、形心、二阶矩与第 12 节的封闭多边形公式相比,连同它们对坐标的导数与 geometry.js 运算图导数相比,最大相对差为 ,共 168 项(本页代码在构建时计算)
Check: for all 24 cells the 7-point quadrature area, centroid and second moments agree with the closed polygon formulas of Section 12, and their coordinate derivatives with the graph derivatives of geometry.js, to a maximum relative difference of over 168 items (computed by this page's code at build time)
Jacobian 与原始求解
Jacobian and primal solve
选择残差块行 i 与状态块列 j,查看 4×4 导数块,并在网格上辨认依赖邻域。
Choose block row i and column j, inspect the 4×4 derivative block, and locate the stencil on the mesh.
精确算子与便宜的三角修正
Exact operators and inexpensive triangular corrections
在同一当前状态将对流切换为一阶,仍完整保留二次黏性重构、物性和边界。块对角部分 的每个对角块为 , 是严格下三角部分;梯度依赖会产生远于最近邻的块。当前小网格预处理显式组装 ,主导数接口则通过计算图提供 和 。
At the same state, switches convection to first order while retaining quadratic viscosity reconstruction, transport and boundaries. Its block diagonal has blocks and is its strictly lower part; gradient dependencies extend beyond nearest neighbours. The small-grid preconditioner explicitly assembles , while main derivative interfaces provide graph-based and .
Newton 默认用分层 ASM/ILU 预处理 FGMRES,GS 保留为对照;回溯步长要求正密度、正温度、支持的速度范围以及残差下降。收敛后重新线性化,稳态伴随不求解 Newton 迭代轨迹的反向传播。下方残差是原始非线性残差,不能与线性迭代混作一条曲线。
Newton defaults to hierarchical ASM/ILU-preconditioned FGMRES, with GS kept for comparison, and backtracking for positive density and temperature, supported speeds and residual decrease. Relinearize at convergence; the steady adjoint does not reverse the Newton iteration trajectory. The nonlinear residual below is distinct from inner linear convergence.
读懂诊断表
Reading the diagnostics table
| 键Key | 默认基准的值Default baseline | 含义与接受条件Meaning and acceptance condition |
|---|---|---|
| minRho | 9.635e-1 | 所有面求积点上两侧对流迹与黏性迹中的最小密度;要求大于 0.1。Smallest density among all convective and viscous traces at face Gauss points; must exceed 0.1. |
| minT | 9.546e-1 | 同样位置上的最小温度;要求大于 0.2。Smallest temperature at the same points; must exceed 0.2. |
| maxWave | 1.459e+0 | 最大特征速度 |un|+c;必须小于固定耗散速度 3,否则 Rusanov 耗散不足。Largest characteristic speed |un|+c; must stay below the fixed dissipation speed 3, otherwise Rusanov dissipation is insufficient. |
| minBoundaryMargin | 2.145e-3 | 非固壁边界上带符号的法向速度:入口取 −un,出口取 +un,取所有求积点的最小值;要求 ≥ −10⁻¹⁰,即入口确实流入、出口确实流出,固定分区没有被穿越。Signed normal velocity on non-wall boundaries: −un at inflow and +un at outflow, minimised over Gauss points; must be ≥ −10⁻¹⁰, i.e. inflow really enters, outflow really leaves and the fixed partition is not crossed. |
| reproductionError | 3.709e-15 | 二次重构复现 5 个基函数的最大误差;超过 10⁻⁸ 说明几何矩阵失去多项式精确性。Largest error in reproducing the five basis monomials; above 10⁻⁸ the geometric fit has lost polynomial exactness. |
| supported | 是true | 以上条件同时成立;否则求解报错而不返回结果。All conditions above hold; otherwise the solve reports an error instead of returning a result. |
默认基准的 minBoundaryMargin 只有 :有一个边界面几乎与流动相切。它出现在顶点 6–7 之间的内圈入口面,求积点 ,法向 ,。固定分区仍然成立,但已接近附录 A.8 提醒的 退化情形——那里未约简的出口条件只剩 ,不能再除以 ,适定性也需要重新分析。参数或几何扰动若使 越过零,本页会报告“离开支持分支”,而不是悄悄切换入口/出口类型。
The default minBoundaryMargin is only : one boundary face is almost tangent to the flow. It occurs on the inner-boundary inflow face between vertices 6 and 7, at Gauss point with normal and . The fixed partition still holds, but the face is close to the degeneracy flagged in Appendix A.8, where the unreduced outflow condition is only , cannot be divided by , and well-posedness needs a fresh analysis. If a parameter or geometry perturbation pushed across zero, this page reports leaving the supported branch instead of silently switching the inflow/outflow type.
Newton 线性化与四乘四块
Newton linearization and four-by-four blocks
将第 i 个四分量残差对第 j 个四分量状态求偏导,得到 块 。例如第一行、第二列是第 i 个质量残差对第 j 个 x 动量的导数。坐标和物理参数在这个偏导中保持固定:
Differentiating four residual components at i with respect to four state components at j produces a block . Its first-row, second-column entry differentiates mass residual i with respect to x-momentum j, holding coordinates and parameters fixed:
希望下一步残差接近零,忽略二次小量后就得到 。Newton 步很大时,高阶项不能忽略,回溯才有必要。预处理只用来更便宜地解这个线性系统;它不能用一阶近似 Jacobian 替代实际二阶残差的精确 Jacobian。
Setting the linearized residual to zero gives . Large steps make neglected higher terms important, motivating backtracking. Preconditioning accelerates this linear solve; it does not replace the required exact second-order Jacobian by a first-order one.
matrix-free 的含义是“计算矩阵乘向量而不存储整个主矩阵”,不是“不存在 Jacobian”。代码沿局部计算链求得 或 ;用于教学的小网格仍显式存储预处理矩阵 。
Matrix-free means applying the main matrix without storing it, not that a Jacobian does not exist. Local chains produce or , while the small teaching-grid preconditioner is explicitly stored.
逐步推导: 的哪些块不为零
Step by step: which blocks of are nonzero
第一步,面 的点通量只读取 (第 3 节)。
Step 1, the point fluxes of face read only (Section 3).
第二步,面循环把 加到左侧行、从右侧行减去,所以原始残差的块 不为零,当且仅当某个以 为一侧的面读取了 :
Step 2, the face loop adds to the rows of and subtracts it from those of , so block of the raw residual is nonzero exactly when some face with on one side reads :
第三步,比较一阶对流的预处理矩阵 。一阶对流只读 ,但黏性通量仍读取整个 。
Step 3, compare the first-order preconditioner . First-order convection reads only , but the viscous flux still reads the whole of .
因此格心页上 与 的块结构完全相同:图 6.1 中两者都有 432 个非零块(共 576 块),其中只有 88 个是对角块或面邻居块。换成一阶对流改变的是数值,不是稀疏结构。附录 C 统计的默认制造解基准同样是 432 个。
So on the cell page has exactly the block pattern of : in Figure 6.1 both have 432 nonzero blocks (of 576), of which only 88 are diagonal or face-neighbour blocks. Switching convection to first order changes values, not sparsity. The default manufactured baseline counted in Appendix C has the same 432.
核对:由上述步骤预测的块结构与本页逐列 JVP 组装的 相比,576 个块中有 0 个不同(本页代码在构建时计算)
Check: the block pattern predicted by these steps differs from that of assembled column by column with the page's JVP in 0 of 576 blocks (computed by this page's code at build time)
目标函数与壁面收支
Objectives and wall budgets
先判断当前目标在哪里积分,再区分压力、黏性和热流贡献。制造解内圈不是固壁。
Locate the integration domain, then separate pressure, viscous and heat contributions. The MMS inner boundary is not a wall.
离散目标先于目标梯度
Define the discrete objective before its gradient
流体域外法向在物面指向物体,因此流体对物体的合力为 。热流正号表示从流体流出到物体。制造解的内圈不是固壁,其“力”选项只是同一内边界牵引泛函。目标求积的重构、温度转换、法向、面长和壁温都属于目标导数;不能只对最终压力数值求导。
At the body the fluid-outward normal points into the body, so fluid-on-body force is . Positive heat flows out of the fluid into the body. The manufactured inner boundary is not a solid wall; its force option is the same mathematical traction functional. Objective derivatives include reconstruction, temperature conversion, normals, length and wall temperature.
压力和摩擦分别列出,再检查它们之和。温度跟踪使用一阶/二阶对应的对流重构;不能把它与黏性二次迹混用。热流对物性参数常有显式项,固定流场也不一定为零。
Display pressure and viscous contributions separately and verify their sum. Tracking uses the selected first/second-order convective reconstruction, not the quadratic viscous trace. Heat objectives generally have direct transport-parameter terms even at fixed flow.
这个例子的壁面热流目标 (负号表示热量从壁面流入流体),x/y 合力为 ,其中黏性部分 。伴随敏感度最大的是顶点 5(极角 ),。这组数据与第 11 节的动画都由 run_flow_evidence.js 用本页代码在第 2 级加密网格上离线算出;浏览器里的层流算例用的是 24 个控制体的基网格。
For this case the wall-heat objective is (negative: heat flows from the wall into the fluid) and the x/y force is , with viscous part . The largest adjoint sensitivity is at vertex 5 (polar angle ), . These data and the animation in Section 11 were computed offline by run_flow_evidence.js with this page's code on refinement level 2; the browser's own flow case uses the 24-volume base mesh.
加密后的层流算例
The flow case under refinement
同一算例逐级加密(run_flow_levels.js,第 0–4 级,与 A.18 相同的稀疏精确 Newton 与伴随)可以看出上图离收敛还有多远。从第 3 级到第 4 级,格心的 变化 0.026%,x 合力变化 0.78%;格点分别变化 6.6% 与 16%。第 4 级上两种格式的 仍相差 6.8%,x 合力相差 14%。格点的壁面量收敛更慢。一个可能的原因(这里没有单独验证)是:格点的壁面节点正好落在多边形的角点上,而角点附近的流场梯度是奇异的;第 15 节的无滑移制造解在角点处是光滑的,检验不到这一点。上图所用的第 2 级网格上,本页格式的 与第 4 级相差 1.2%。
Refining the same case (run_flow_levels.js, levels 0–4, with the same sparse exact Newton and adjoint as A.18) shows how far the figure is from converged. From level 3 to level 4, the cell values of and of the x force change by 0.026% and 0.78%; the node values by 6.6% and 16%. At level 4 the two schemes still differ by 6.8% in and 14% in the x force. The nodal wall quantities converge more slowly. A likely reason, not isolated here, is that the wall nodes sit exactly on the polygon corners, where the flow gradients are singular; the no-slip manufactured solution of Section 15 is smooth at the corners and cannot test this. On the level-2 mesh of the figure, this page’s differs from its level-4 value by 1.2%.
| 级Level | 控制体(格心/格点)Volumes (cell / node) | Q 格心Q, cell | Q 格点Q, node | x 合力 格心x force, cell | x 合力 格点x force, node |
|---|---|---|---|---|---|
| 0 | 24 / 24 | -1.216e-1 | -2.931e-2 | 5.034e-1 | 2.452e-1 |
| 1 | 96 / 80 | -1.500e-1 | -7.040e-2 | 6.776e-1 | 2.228e-1 |
| 2 | 384 / 288 | -1.601e-1 | -1.198e-1 | 5.786e-1 | 3.199e-1 |
| 3 | 1536 / 1088 | -1.620e-1 | -1.416e-1 | 5.471e-1 | 3.995e-1 |
| 4 | 6144 / 4224 | -1.620e-1 | -1.510e-1 | 5.428e-1 | 4.653e-1 |
把图中逐顶点的敏感度加起来,就是所有壁面顶点沿各自法向外移单位距离时 的导数(伴随梯度沿这一方向的分量;几何项由一次复数步长求值得到)。第 4 级格心为 ,格点为 ,格点约为格心的 2.0 倍。其中一部分并不是形状效应:只移动壁面顶点也会压扁第一层控制体,离散误差对这种压扁的导数只会随壁面附近的离散误差一起减小,而这里壁面附近收敛得慢。下表比较两个边界运动相同(壁面上都是径向单位向量)、因而连续导数相同的方向:只移动壁面顶点时,第 4 级格点值约为格心值的 1.7 倍;让全部顶点按光滑场 移动( 时 ,到 光滑降为 0,外边界不动)时相差 4.2%。
Adding up the vertex values of the figure gives the derivative of when every wall vertex moves outward by a unit distance along its own normal (the adjoint gradient along that direction; the geometric terms come from one complex-step evaluation). At level 4 it is for cells and for nodes, a factor of 2.0 apart. Part of this is not a shape effect: moving only the wall vertices also squeezes the first layer of control volumes, and the derivative of the discretization error with respect to that squeeze decreases only as fast as the error next to the wall, which converges slowly here. The table below compares two directions with the same boundary motion (the radial unit vector on the wall), hence the same continuum derivative: moving only the wall vertices, the node value is 1.7 times the cell value at level 4; moving all vertices with the smooth field ( for , decreasing smoothly to 0 at , outer boundary fixed), they differ by 4.2%.
| 级Level | 只动壁面顶点,沿顶点法向(格心/格点)wall vertices only, vertex normals (cell / node) | 只动壁面顶点,沿径向wall vertices only, radial | 全部顶点光滑移动,壁面沿径向all vertices, smooth radial field |
|---|---|---|---|
| 0 | 6.66e-2 / -1.07e-1 | 7.46e-2 / -1.10e-1 | -1.84e-1 / -6.41e-2 |
| 1 | -2.59e-2 / -1.91e-1 | -2.33e-2 / -1.83e-1 | -9.53e-2 / -7.63e-2 |
| 2 | -1.29e-1 / -3.58e-1 | -1.16e-1 / -3.25e-1 | -1.46e-1 / -1.50e-1 |
| 3 | -1.61e-1 / -3.53e-1 | -1.44e-1 / -2.97e-1 | -1.55e-1 / -1.52e-1 |
| 4 | -1.70e-1 / -3.40e-1 | -1.52e-1 / -2.53e-1 | -1.58e-1 / -1.64e-1 |
逐顶点的值是离散 的精确导数:在顶点 5 沿法向作 的全重算中心差分(run_flow_fd.js),得到 ,伴随为 ;两个核对顶点的最大相对差为 。但它们同样带有离散误差,也包含上面的网格压扁效应:本页的图中全部顶点的值都为负。所以图中的敏感度演示的是一次伴随求解如何给出全部顶点的导数,不能直接当作连续问题的形状梯度;形状优化应通过光滑的网格运动(第 12 节的网格映射 )使用它们。
The vertex values are exact derivatives of the discrete : a reconverged central difference with along the normal of vertex 5 (run_flow_fd.js) gives against the adjoint , and the largest relative difference over the two checked vertices is . They carry the same discretization error, including the squeeze effect above: in this page’s figure every vertex value is negative. The sensitivities in the figure therefore show how one adjoint solve yields the derivative for every vertex; they are not a converged continuum shape gradient, and shape optimization should use them through a smooth mesh motion (the mesh map of Section 12).
温度跟踪目标的每一项导数
Every term in the temperature-tracking derivative
把每个求积点温度误差记为 。平方项中的 与求导产生的 2 抵消,但几何方向还会改变权重和目标函数的采样位置:
Let . Differentiating cancels the half with the derivative of a square. Geometry directions also change weights and the sampled target:
固定状态仍可能有非零目标导数,例如移动面改变力的法向与面长,改变黏度会改变剪应力。力目标中的一个求积点可展开为:
An objective can change at fixed state: moving a face changes its normal and length, and viscosity changes shear. One force-quadrature contribution expands as:
这就是后面把总梯度拆成“目标显式项”和“流场响应项”的原因。列梯度的定义是 ; 是列, 按通常 Jacobian 约定是行。两种写法不能在同一个等式里漏掉转置。
This explains the later direct-objective and state-response contributions. A column gradient satisfies ; is a column, whereas the usual Jacobian is a row. Keep the transpose explicit.
逐步推导:壁面力与壁面热流的偏导数
Step by step: partial derivatives of the wall force and wall heat
第一步,写出一个固壁求积点的贡献(core.js 面循环末尾的目标累加)。等温壁上罚项把温度梯度的法向分量写成显式形式:
Step 1, write the contribution of one wall quadrature point (the objective accumulation at the end of the face loop in core.js). At an isothermal wall the penalty makes the normal temperature gradient explicit:
这里用了 。绝热壁上 ,热流目标恒为零。
This uses . On an adiabatic wall and the heat objective vanishes identically.
第二步,对状态求偏导。等温壁上 不依赖状态,热流只通过内侧迹及其斜率依赖状态:
Step 2, take state partials. At an isothermal wall is independent of the state, and the heat flux depends on it only through the interior trace and its slopes:
, 的分量是 ,所以用第 3 节的行与二阶行得到迹值与迹斜率上的权重:
Since and the components of are , the rows and second rows of Section 3 give the weights on the trace value and slopes:
再经第 9 节第七步的拟合转置,把它们送到邻域 的全部状态;对所有固壁求积点求和就是 。力目标同理: 给出 ; 经应力反传给出速度梯度的权重,再经罚项 同时作用于 与 。
The fit transpose of Section 9, Step 7, then sends them to every state of the stencil ; summing over all wall quadrature points gives . The force works the same way: gives ; gives velocity-gradient weights through the stress reverse, which act on both and through the penalty .
第三步,对设计参数求显式偏导(状态固定)。、,壁温同时出现在罚项、物性和壁面压力中:
Step 3, take explicit parameter partials at fixed state. and ; the wall temperature appears in the penalty, in transport and in the wall pressure:
第四步,对坐标求显式偏导。权重、法向和边界距离 (格心)都随坐标变化;由乘积法则
Step 4, take explicit coordinate partials. The weight, the normal and the boundary distance (cells) all move with the coordinates; by the product rule
与 来自第 12 节; 在坐标方向上还包含拟合矩阵的 tangent 。格点版本的 ,。图 7.2 画出三个目标的 在网格上的分布:温度跟踪遍布全域;壁面目标只读取固壁邻域,在这张小网格上它已经覆盖全部控制体,但数值集中在第一层。
and come from Section 12; along coordinate directions also contain the tangent of the fit . On the node page and . Figure 7.2 maps for three objectives: temperature tracking covers the whole domain; the wall objectives read only the wall stencils, which on this small mesh already cover every control volume, but their values concentrate in the first layer.
核对:由这些公式手工组装的 与参数偏导 (温度跟踪、 力、 力、热流,常物性与 Sutherland,制造解内边界与物理固壁),与本页运算图在残差种子 、目标种子 时的反向结果相比,最大相对差为 和 ;第四步的局部 tangent 由 check_enrichment_sympy.py 符号验证(本页代码在构建时计算)
Check: and the parameter partials assembled by hand from these formulas (temperature tracking, force, force, heat; constant and Sutherland transport; manufactured inner boundary and physical wall) agree with the page's reverse pass seeded with and to a maximum relative difference of and ; the local tangent of Step 4 is verified symbolically by check_enrichment_sympy.py (computed by this page's code at build time)
matrix-free 前向
Matrix-free tangent
先清零种子,再在一个状态的一个分量置 1。改变“查看分量”,观察方程之间的耦合。
Clear the seed and set one state component to one. Change the view component to see coupling between equations.
先传播局部变量转换
First propagate local variable conversion
拟合系数和几何必须一起求导
Differentiate fitted coefficients and geometry together
固定几何的状态方向中 ;坐标方向中距离、权重、面积质心与矩变化,不能冻结 。原始变量梯度由守恒多项式梯度转换,其 tangent 同时包含转换 Jacobian 的变化和多项式梯度的变化。
State directions at fixed geometry have . Coordinate directions change distances, weights, centroids and moments, so cannot be frozen. Tangents of primitive gradients include both changes in the conversion Jacobian and in the conservative polynomial gradient.
点号 是离散代码的前向(tangent)导数,上横线 (第 9 节)是反向(adjoint)导数,都是自动微分的写法;变分符号 只出现在附录 A,表示连续函数的变分。代码与播放器把 、 写成 x_d、x_b(Tapenade 约定,如 residual_d、residual_b)。点号不是时间导数。设计种子 为零(固定参数与坐标)时输出 ;状态种子为零时输出 ,即设计右端。播放器采用固定状态方向,第 10 节才解设计导致的状态响应。
The dot is the forward-mode (tangent) derivative of the discrete code and the bar (Section 9) its reverse-mode (adjoint) derivative, in the notation of algorithmic differentiation; the variation symbol appears only in Appendix A, as the variation of a continuous function. Code and players write and as x_d and x_b (the Tapenade convention, e.g. residual_d, residual_b). Dots are not time derivatives. A zero design seed (fixed parameters and coordinates) gives ; a zero state seed gives , the design right-hand side. The player uses a fixed state direction; Section 10 solves for the response induced by design.
seed U_d (state), D_d = (a_d, X_d) (design: parameters, coordinates)
for z = f(inputs) in forward order:
z_d = sum(partial_f_partial_input * input_d)
# multiplication z = a*b: z_d = b*a_d + a*b_d
# include reconstruction, viscous tensor, boundary and quadrature
return R_d, J_d方向导数就是多元函数的一阶变化
Directional derivatives are first-order changes
例如 ,展开 后,保留一次项就得到 。这就是前向(tangent)模式:每个程序变量 带着自己的 。本页的 AD(自动微分)只是在实际代码的每一步应用这条链式法则,不用差分步长近似偏导。
For , expanding gives at first order. This is forward (tangent) mode: every program variable carries its own . Automatic differentiation applies such chain rules to actual code operations, without difference-step approximations.
例如速度梯度本身也要再求导。把 当作一个普通多元代数函数:
The reconstructed velocity gradient must itself be differentiated. Treat as an algebraic function:
因此只对梯度系数求导、却冻结从守恒量到速度的转换,也是不完整的。拟合系数沿几何方向的 tangent 来自对 直接求导:
Differentiating fit coefficients while freezing conservative-to-velocity conversion is incomplete. Tangents of the fit along geometry directions follow by differentiating :
继续展开对流和黏性通量
Continue through convective and viscous fluxes
固定耗散速度是 3,所以这里没有“耗散速度的导数”。Sutherland 黏度可先取对数微分,得到显式温度偏导;常黏度模式则只有这个温度偏导为零:
The dissipation speed is fixed at three, so it has no derivative. Logarithmic differentiation gives the Sutherland temperature derivative; only that derivative vanishes in constant-viscosity mode:
这条链在求积后给出 ,再按“左加右减”得到 。若只想计算 ,就将参数和坐标种子置零;若构造设计右端,就将状态种子置零。
Quadrature produces , then conservative scatter produces . Set parameter and coordinate seeds to zero for ; set state seeds to zero to form a design right-hand side.
图 8.2 把残差画成依次执行的几遍:每一遍读一组存储数组、写一组新数组。前向沿图从左到右传播方向,反向从右到左传播权重。
Figure 8.2 draws the residual as a sequence of passes, each reading stored arrays and writing new ones. The tangent carries directions left to right; the reverse carries weights right to left.
逐步推导:一个内部 Gauss 点的切线,逐遍传播
Step by step: the tangent of one interior Gauss point, pass by pass
第一步,迹遍(core.js 的 rec())。状态方向 先进入拟合:,。在面点 处,用第 3 节的基函数行 及其斜率:
Step 1, the trace pass (rec() in core.js). The state direction first enters the fit: with . At the face point use the basis row of Section 3 and its slopes:
最后一式是二阶对流的迹, 只保留 的两个线性分量;一阶对流时 。几何固定时 不变,这一遍对状态是线性的。
The last formula is the second-order convective trace, where keeps only the two linear entries of ; with first-order convection . At fixed geometry do not change, so this pass is linear in the state.
第二步,原始量遍:用第 3 节的行和二阶行,
Step 2, the primitive pass, with the rows and second rows of Section 3:
右侧 同样处理。
The right side is treated in the same way.
第三步,面梯度遍。面梯度是两侧梯度的平均加上沿 的跳跃项;几何固定时 不变:
Step 3, the face-gradient pass. The face gradient is the average of both sides plus a jump term along ; at fixed geometry does not change:
坐标方向还要加上 的贡献:
Coordinate directions add the term
跳跃项沿 而不是沿 ;图 8.3 画出实际网格上两者的夹角。
The jump acts along , not along ; Figure 8.3 shows the angle between them on the actual mesh.
第四步,物性与本构遍:
Step 4, the transport and constitutive pass:
第五步,通量遍。黏性部分用第 3 节的 Jacobian;对流部分是 Rusanov(局部 Lax–Friedrichs)通量,耗散速度固定为 3,没有导数:
Step 5, the flux pass. The viscous part uses the Jacobian of Section 3; the convective part is the Rusanov (local Lax–Friedrichs) flux with the dissipation speed fixed at 3, which has no derivative:
第六步,求积与分发遍:
Step 6, the quadrature and scatter pass:
边界面按第 4 节处理:没有右侧迹;第三步用边界状态和单侧罚项梯度 ; 给定时(等温壁、入口)第四步中 ;第五步的 Rusanov 通量换成固壁压力通量 、入口规定通量 或出口牵引闭合(出口没有黏性路径)。
Boundary faces follow Section 4: no right trace; Step 3 uses the boundary state and the one-sided gradient ; where is prescribed (isothermal wall, inlet) Step 4 has ; Step 5 replaces the Rusanov flux by the wall pressure flux , the prescribed inlet flux or the outlet traction closure, which has no viscous path.
核对:用这六遍(及第 4 节边界)手工组装的 ,在 6 种配置(制造解与物理绕流、一阶与二阶、两种物性、等温与绝热)上与本页运算图的 JVP 相比,最大相对差为 ;同样公式重算的点通量值与面循环记录的值相差 (本页代码在构建时计算)
Check: assembled by hand from these six passes (with the boundaries of Section 4) agrees with the page's graph JVP over 6 configurations (manufactured and physical flow, first and second order, both transport laws, isothermal and adiabatic) to a maximum relative difference of ; the point fluxes recomputed by the same formulas differ from the values recorded by the face loop by (computed by this page's code at build time)
逐步推导:面循环的逐条语句及其前向语句
Step by step: the face loop statement by statement, and its tangent statements
上面六遍按数组分组。现在把 core.js 的 evaluate() 在内部面一个 Gauss 点上所做的计算写成一串直线语句 S1–S11:每条语句只读前面语句的结果。前向程序按同样的顺序执行同样的语句,只是每条换成它的线性化(点号)。几何和参数固定(),只有状态方向 在传播。表中 ,,;S1 里的 是邻居 在迹中的权重,下面第一步再由第 3 节的拟合导出它;第 9 节把同一张表倒过来读。
The six passes above group the computation by arrays. Here the computation that evaluate() in core.js performs at one Gauss point of an interior face is written as a straight-line program of statements S1–S11, each reading only results of earlier statements. The tangent program runs the same statements in the same order, each replaced by its linearisation (dots). Geometry and parameters are fixed (), so only the state direction propagates. In the table , , ; in S1 is the weight of neighbour in the trace, which Step 1 below derives from the fit of Section 3; Section 9 reads the same table backwards.
| 语句statement | 原始(core.js)primal (core.js) | 前向tangent |
|---|---|---|
| S1 | ||
| S2 | ||
| S3 | ||
| S4 | ||
| S5 | ||
| S6 | ||
| S7 | ||
| S8 | ||
| S9 | ||
| S10 | ||
| S11 |
其中 是第 3 节应力公式中的矩阵,所以 ; 与二阶行 是第 3 节“原始变量、Jacobian 行与梯度转换”的结果; 在常物性时为零。
Here is the matrix of the stress formulas of Section 3, so ; and the second rows are those of Section 3, “primitive variables, Jacobian rows and the gradient conversion”; is zero under constant transport.
第一步,把拟合与迹合成邻域权重。第 3 节的拟合给出 ,迹是 。把两式合在一起,迹就是邻域差值的加权和:
Step 1, combine the fit and the trace into stencil weights. The fit of Section 3 gives and the trace is . Put together, the trace is a weighted sum of neighbour differences:
是一个标量:邻居 在点 的迹中所占的权重,四个守恒分量共用;斜率权重就是它的空间导数 (用 代替 ),二阶对流的线性迹用只保留 两个线性分量的 。几何固定时这些权重是常数,所以前向就是同一个“收集”:,黏性梯度的前向经过重构正是这一步。它们复现线性场:, 在 时为 1、否则为 0。图 8.4 画出例子 Gauss 点上两侧的权重。
is a scalar, the weight of neighbour in the trace at , shared by the four conserved components; the slope weights are its spatial derivatives ( in place of ), and the linear trace of second-order convection uses , which keeps only the two linear entries of . At fixed geometry the weights are constants, so the tangent is the same gather, : this is how the tangent of the viscous gradient passes through the reconstruction. They reproduce linear fields: and is 1 for and 0 otherwise. Figure 8.4 draws the weights of both sides at the example Gauss point.
第二步,写出对流通量的 Jacobian。把第 1 节的对流通量写成 ,用乘积法则,并代入第 3 节的 和 :
Step 2, write out the convective flux Jacobian. Write the convective flux of Section 1 as , apply the product rule and substitute and of Section 3:
是单位质量总焓(本页 )。乘开得到完整矩阵:
is the total enthalpy per unit mass (here ). Multiplied out:
它的特征值是 ,,正是第 1 节数边界条件所用的特征速度;Rusanov 通量的两个 Jacobian 是 ,固定耗散速度 3 只加单位矩阵。紧凑形式在反向时更省:第 9 节用它把转置乘积写成两个点积加一次缩放。
Its eigenvalues are with , the characteristic speeds counted in Section 1; the two Jacobians of the Rusanov flux are , the fixed dissipation speed 3 adding only the identity. The compact form pays off in reverse: Section 9 uses it to write the transposed product as two dot products and a scaling.
核对:在例子 Gauss 点的左侧对流迹上(,),这两种写法与 physics.js 的 flux() 经运算图求导及复数步长求导的结果相比,最大相对差为 ;()与 的相对差为 ,所以特征值正是这四个(本页代码在构建时计算)
Check: at the left convective trace of the example Gauss point (, ) both forms agree with the graph derivative and the complex-step derivative of flux() in physics.js to a maximum relative difference of ; the traces , , agree with to a relative , so these four are the eigenvalues (computed by this page's code at build time)
第三步,热流与 Sutherland 律沿链的导数。S6 与 S8 合起来,热流对面状态温度与面梯度的导数是
Step 3, the heat flux and Sutherland's law along the chain. Together, S6 and S8 give the derivative of the heat flux with respect to the face temperature and the face gradient:
按 Sutherland 律取值,常物性时 。第一项只在 Sutherland 律下存在,它经 、 回到两侧的迹。第二项经面梯度(S4)回到两个邻域,同时用到斜率权重 和值权重 :两侧梯度的平均 带着斜率权重(S2 的二阶行还带上值权重),跳跃项 只经值权重( 是 S2 中两侧迹的温度的点号值)。第四步的例子(常物性)里,跳跃项占第二项的 96%。设计方向另有 与 两条路径(第 10 节)。
follows Sutherland's law and is zero under constant transport. The first term exists only under Sutherland's law and returns through and to the traces of both sides. The second term returns to the two neighbourhoods through the face gradient (S4) and uses both the slope weights and the value weights : the average of the two side gradients carries the slope weights (the second rows of S2 add the value weights), while the jump term goes through the value weights only ( are the dotted temperatures of the two side traces in S2). In the example of Step 4 (constant transport) the jump term makes up 96% of the second term. Design directions add the paths and (Section 10).
第四步,用本页的数算一遍。取物理绕流基准(二阶、常物性、等温壁)上第 1、3 节图中的内部面 0(单元 0→1)的第 1 个 Gauss 点(,),状态方向用附录 B.1 的 ( 是 96 个状态分量的编号)。下表每行是一条语句的输出和它的点号值:
Step 4, run it with this page's numbers. Take the physical-flow baseline (second order, constant transport, isothermal wall), the first Gauss point of interior face 0 (cell 0→1, the face of the figures of Sections 1 and 3; , ), and the state direction of Appendix B.1 ( numbers the 96 state components). Each row is the output of one statement and its dotted value:
| 语句statement | 量quantity | 值value | 前向(点号)tangent (dot) |
|---|---|---|---|
| S1 | (9.372e-1, 5.971e-2, 1.924e-2, 2.451e+0) | (1.010e-1, 6.659e-1, 6.186e-1, 2.552e-3) | |
| S1 | (9.394e-1, 7.671e-2, 1.733e-2, 2.462e+0) | (-7.744e-1, -6.127e-1, 1.123e-1, 7.341e-1) | |
| S1 | (9.303e-1, 4.646e-2, 1.498e-2, 2.436e+0) | (1.761e-1, 8.493e-1, 7.416e-1, -4.789e-2) | |
| S1 | (9.279e-1, 8.021e-2, 1.432e-2, 2.435e+0) | (-8.623e-1, -7.067e-1, 9.866e-2, 8.133e-1) | |
| S2 | (6.371e-2, 2.053e-2, 1.045e+0) | (7.036e-1, 6.578e-1, -1.350e-1) | |
| S2 | (8.166e-2, 1.845e-2, 1.047e+0) | (-5.849e-1, 1.348e-1, 1.195e+0) | |
| S3 | (7.270e-2, 1.949e-2, 1.046e+0) | (5.442e-2, 3.965e-1, 5.281e-1) | |
| S4 | (-7.432e-2, 1.450e-1, 2.546e-2, 3.179e-2, 2.921e-3, -1.573e-2) | (3.107e+0, -2.874e+0, 1.261e+0, -1.506e+0, -3.264e+0, 2.436e+0) | |
| S5 | (-3.549e-2, -7.990e-1, 6.260e-1, -1.411e-1) | (1.773e+0, 2.244e+0, 1.053e+0, -6.117e-1) | |
| S6 | (2.000e-1, 9.722e-1) | (0, 0) | |
| S7 | (-2.406e-2, 3.409e-2, 1.839e-2) | (1.029e+0, -3.226e-1, -8.158e-1) | |
| S8 | (4.034e-2, -1.429e-2, 1.201e-2) | (-9.958e-1, -2.773e-1, -3.953e+0) | |
| S9 | (0, 4.034e-2, -1.429e-2, -9.355e-3) | (0, -9.958e-1, -2.773e-1, 3.872e+0) | |
| S10 | (-3.549e-2, -8.393e-1, 6.403e-1, -1.317e-1) | (1.773e+0, 3.240e+0, 1.330e+0, -4.484e+0) |
面 0 共有 2 个 Gauss 点;把每个点的 加起来得到
Face 0 has 2 Gauss points; adding over them gives
这就是附录 B.1 实验里这个面的 (切到“层流绕流”并重新计算后选面 0)。
This is the of this face in the lab of Appendix B.1 (switch to laminar flow, recompute and pick face 0).
核对:表中每一个点号值都与把复数状态 ()送进 core.js 的 evaluate() 后该 Gauss 点记录量的虚部除以 相比,最大相对差为 ;面 0 的 与本页运算图的 JVP 相比为 (本页代码在构建时计算)
Check: every dotted value of the table agrees with the imaginary part, divided by , of the quantity the face loop of evaluate() in core.js records at this Gauss point when it is run on the complex state (), to a maximum relative difference of ; of face 0 agrees with the page's graph JVP to (computed by this page's code at build time)
第五步,固壁面。同一个 下,单元 0 的固壁面 32 的第 1 个 Gauss 点按第 4 节的固壁分支执行:没有右侧迹,壁面状态 ,梯度用单侧罚项 (),对流部分只剩压力:
Step 5, the wall face. For the same , the first Gauss point of wall face 32 of cell 0 runs the wall branch of Section 4: no right trace, the wall state , the one-sided penalty gradient (), and only pressure left in the convective part:
| 语句statement | 原始primal | 前向tangent | 值value | 点号值dotted value |
|---|---|---|---|---|
| W1 | (9.147e-1, 3.174e-2, 3.898e-3, 2.398e+0) | (3.761e-1, 1.093e+0, 8.050e-1, -2.231e-1) | ||
| W2 | (9.147e-1, 9.604e-1) | (3.761e-1, 3.949e-1) | ||
| W3 | (2.302e-1, 1.436e-1, 5.603e-2, 3.261e-2, -1.737e-3, -1.238e-2) | (8.745e+0, 2.818e+0, 6.661e+0, 2.286e+0, -3.775e+0, -1.059e+0) | ||
| W4 | (2.000e-1, 9.722e-1) | (0, 0) | ||
| W5 | (-6.741e-2, -2.970e-2, -5.793e-3) | (-2.562e+0, -1.581e+0, -3.798e+0) | ||
| W6 | (0, -8.323e-1, -3.063e-1, -5.793e-3) | (0, 2.192e+0, 1.443e+0, -3.798e+0) |
这个面的 进入 单元 0 的残差,同时进入目标: 方向合力 ,全面求和 (面 32 对 的贡献)。
The of this face enters the residual of cell 0 and the objective: the force , summed over the face (the share of face 32 in ).
核对:固壁点的点号值与复数步长的差为 ,面 32 的 与运算图 JVP 的差为 ;六种配置(绕流的常物性、Sutherland、绝热壁、一阶,以及两种制造解)下全部 288 个面、576 个 Gauss 点(内部 384、固壁 64、入口 64、出口 64)逐点用这些语句算出的 与复数步长相比为 ,逐面的 与 JVP 相比为 (本页代码在构建时计算)
Check: the dotted values of the wall point agree with the complex step to and of face 32 with the graph JVP to ; over six configurations (the flow with constant transport, Sutherland, adiabatic wall and first order, and two manufactured baselines), the statements give at all 576 Gauss points of all 288 faces (interior 384, wall 64, inlet 64, outlet 64) within of the complex step, and face by face within of the JVP (computed by this page's code at build time)
图 8.5 把这些语句沿面循环连起来:状态方向只放在单元 11 的能量分量上(),逐面执行 S1–S11 并把 累加到 。只有读取集合 含单元 11 的面有贡献(48 个面中的 28 个);因为二次拟合的邻域有两三圈, 的这一列延伸到离单元 11 4 圈远的控制体,共 16 个非零块。
Figure 8.5 strings the statements along the face loop: the state direction sits on the energy component of cell 11 only (), each face runs S1–S11 and adds into . Only faces whose read set contains cell 11 contribute (28 of the 48 faces); because the quadratic stencils span two or three rings, this column of reaches control volumes 4 rings away from cell 11, 16 nonzero blocks in all.
matrix-free 反向
Matrix-free reverse
用同一个单点权重,逐步观察它怎样返回整个重构邻域。格点版先看强边界行反传。
Use one nonzero row weight and watch contributions return to the whole stencil. For nodes, inspect strong-row reversal first.
转置来自有限求和交换
Transposition exchanges finite sums
先反传强约束,屏蔽被覆盖的旧平衡行。边界缺少右控制体时取右种子为零。内部共享面只得到一个净种子,不分别计算两个不一致的导数。所有共享输入都用 += 累加。
Reverse strong constraints first and mask overwritten balances. Missing exterior volumes have zero right seed. A shared interior face receives one net seed rather than two independently differentiated fluxes. Accumulate every shared-input contribution with +=.
黏性功的反向种子
Reverse seeds from viscous work
以上种子用于内部通量“减去黏性”的路径,尚未包含目标种子、法向和求积权重。继续把牵引反传到应力、物性、梯度,再通过两侧二次迹和邻域拟合回传。出口已消去应力的总牵引闭合必须用自己的反向公式。
These seeds belong to the interior subtract-viscosity path, before objective seeds, normals and quadrature weights. Reverse traction through stress, transport and gradients, then through both quadratic traces and stencil fits. The outflow closure with eliminated stress has its own reverse rule.
这里每个守恒分量各有一列拟合系数,对几何矩阵的贡献要对四个分量求和。一个面的反传可以写入多个邻居,不局限于 L、R;播放器对该面读取集合中的每个状态各显示一次“累加”事件,每次加上该状态的四个分量。
Each conserved component has its own fit coefficients; sum all four contributions to the geometry matrix. A face can write to many neighbours, not just L and R. The player shows one accumulation event per state of the face's read set; each event adds that state's four components.
U_b = 0
U_b += constraints_b(R_b); Rraw_b = mask(R_b) # strong rows first
for face in reverse order:
H_b = Rraw_b[L] - Rraw_b[R]
reverse quadrature, flux, stress, transport, traces and fits
for every dependency j: U_b[j] += contribution[j]
# shared geometry receives every face and source contribution反向权重不是另一种流动物理量
Reverse weights are sensitivities, not another flow quantity
给输出 一个反向权重 ,考察标量 。对于 ,把上一节的 tangent 代入并重新分组:
Give output a reverse weight and consider . For , substitute the tangent of the previous section and regroup:
上横线表示反向权重,不表示平均值。这就是反向(adjoint)模式:语句按逆序执行,每个 先清零(),再用 累加。如果同一输入参与多个运算,各条路径的系数必须相加。矩阵转置就是把“按输出行求和”重新排成“按输入列求和”。共享面的种子差因此可以从一个等式直接读出:
Overbars mean reverse weights, not averages. This is reverse (adjoint) mode: statements run in reverse order, and each starts from and accumulates with . Shared inputs receive sums from all paths. Matrix transposition reorganizes output-row sums into input-column sums. For one shared face:
把应力的反向传播写到每个分量
Reverse stress down to individual components
设三个独立应力分量的反向权重为 。展开 并收集同一梯度的系数:
Let the reverse weights of the three independent stresses be . Expand and collect gradient coefficients:
例如正向变量转换 的反向是 、。温度、重构梯度、两侧平均与跳跃修正继续使用同一收集系数的方法。代码里的反向计算图保留所有这些路径。
For example, reversing gives and . Continue collecting coefficients through temperature, gradients, trace averages and jump correction. The reverse graph retains all these paths.
为什么反传拟合也要解一个转置系统
Why reversing the fit needs a transposed system
由系数直接读出 与 ,再继续反传 。这不是另一个连续伴随方程,而是五个拟合系数的有限维链式法则。
Read off and , then reverse . This is a finite-dimensional chain rule for five fit coefficients, not a continuous adjoint PDE.
逐步推导:把同一个 Gauss 点倒过来
Step by step: reversing the same Gauss point
第 8 节的六遍按相反顺序执行,每一遍换成其转置。输入是面种子 ,输出累加到 (开始时 );所有累加都用 。
Run the six passes of Section 8 in reverse order, each replaced by its transpose. The input is the face seed ; outputs accumulate into (which starts from ), always with .
第一步,分发与求积的转置(边界面上 ):
Step 1, transpose the scatter and the quadrature ( at a boundary face):
第二步,Rusanov(局部 Lax–Friedrichs)对流通量的转置。 的线性化(第 8 节第五步)是两侧迹方向的线性组合,转置就是把各项的转置 Jacobian 作用到种子上;固定速度 3 只贡献常数系数 :
Step 2, transpose the Rusanov (local Lax–Friedrichs) convective flux. The linearisation of (Section 8, Step 5) is a linear combination of the two trace directions, so its transpose applies each term's transposed Jacobian to the seed; the fixed speed 3 contributes only the constant coefficients :
第三步,黏性通量的转置。点通量是 ,所以黏性路径的种子是 ;按第 3 节的 Jacobian 取转置:
Step 3, transpose the viscous flux. The point flux is , so the viscous path is seeded with ; transpose the Jacobian of Section 3:
应力分量到 的公式与本节上文“把应力的反向传播写到每个分量”相同。
The stress components go to by the formulas of “Reverse stress down to individual components” above.
第四步,物性的转置:
Step 4, transpose the transport law:
第五步,面状态与面梯度的转置。平均把权重平分给两侧;跳跃项把 方向上的权重以相反符号送给两侧的迹值:
Step 5, transpose the face state and the face gradient. The average splits a weight equally between the sides; the jump sends the weight along to the two trace values with opposite signs:
第六步,原始量的转置(第 3 节“原始变量、Jacobian 行与梯度转换”第四步的两条路径):
Step 6, transpose the primitive conversion (the two paths of Step 4 of “primitive variables, Jacobian rows and the gradient conversion” in Section 3):
第七步,迹与拟合的转置。把迹值、迹斜率和对流迹的权重收集成拟合系数的权重,再解转置的 系统;四个守恒分量各解一次:
Step 7, transpose the traces and the fit. Collect the weights of trace value, trace slopes and convective trace into a weight on the fit coefficients, then solve the transposed system, once per conserved component:
最后一行来自 与 : 一方面直接出现,一方面在每个差值里以负号出现。
The last line comes from and : appears once directly and once with a minus sign in every difference.
第八步,看清累加的范围。一个面的种子写回整个 ;反过来,状态 收到所有读取它的面的贡献。图 9.1 画出状态 0 的“扇入”:48 个面中有 40 个会写入 ;全部状态的扇入数在 28–40 之间。
Step 8, see how far the accumulation reaches. One face seed writes into all of ; conversely state receives contributions from every face that reads it. Figure 9.1 shows the fan-in of state 0: 40 of the 48 faces write into ; over all states the fan-in is 28–40.
核对:按这七步(连同第 4 节的边界闭合)手工组装的 ,在 6 种配置上与本页运算图的 VJP 相比,最大相对差为 ;用手工的前向与反向计算 与 ,两者相对差为 (本页代码在构建时计算)
Check: assembled by hand from these seven steps (with the boundary closures of Section 4) agrees with the page's graph VJP over 6 configurations to a maximum relative difference of ; with the hand-coded tangent and reverse, and differ by a relative (computed by this page's code at build time)
逐步推导:逆序执行的反向语句
Step by step: the reverse statements, in reverse order
把第 8 节的语句表倒过来读:从 S11 到 S1,每条语句换成它的转置,所有 先置零,再用 += 累加。一个量被几条语句读取,它的上横线就收到几份贡献(例如 来自 A3 与 A2, 来自 A7 与 A6)。表中同样 ;格点页在 A11 之前先反传强边界行并得到屏蔽后的 (第 4 节)。
Read the statement table of Section 8 backwards: from S11 to S1, each statement replaced by its transpose, every set to zero first and then accumulated with +=. A quantity read by several statements receives one contribution from each (for example from A3 and A2, from A7 and A6). Again ; the node page reverses the strong rows before A11 and obtains the masked (Section 4).
| 语句statement | 反向(逆序,+=)reverse (reverse order, +=) |
|---|---|
| A11 | |
| A10 | |
| A9 | |
| A8 | |
| A7 | |
| A6 | |
| A5 | |
| A4 | |
| A3 | |
| A2 | |
| A1 |
第一步,转置的邻域权重。A1 是第 8 节 S1 的“收集”的转置:同一组标量权重,把迹的上横线“散射”回邻域里的每个状态。
Step 1, the transposed stencil. A1 is the transpose of the gather S1 of Section 8: the same scalar weights scatter the bars of the traces back to every state of the stencil.
把 代入,括号里的量就是
Substituting , the bracket is
与上面第七步 、 相同:黏性梯度的伴随就是转置的梯度邻域。 的负号来自 中的 。
the same as Step 7 above with and : the adjoint of the viscous gradient is the transposed gradient stencil. The minus sign on comes from the in .
第二步,对流 Jacobian 的转置,写成语句。 的反向不必组装 矩阵:
Step 2, the transposed convective Jacobian as statements. The reverse of needs no matrix:
合起来,转置的作用是
Together the statements give
正是第 8 节紧凑形式的转置:两个点积加一次缩放。
the transpose of the compact form of Section 8: two dot products and a scaling.
第三步,用本页的数倒着算一遍。在第 8 节第四步的同一个 Gauss 点上,种子取附录 B.1、B.4 的 ,于是 。最后一列是每条反向语句执行后,所有“活着”的量的配对 (点号值取第 8 节的表):
Step 3, run it backwards with this page's numbers. At the same Gauss point as Step 4 of Section 8, seed the face flux with of Appendices B.1 and B.4, so . The last column is, after each reverse statement, the pairing over all quantities still alive (dotted values from the table of Section 8):
| 语句statement | 产生的上横线bars produced | 值value | 配对pairing |
|---|---|---|---|
| A10 | (5.116e-2, -7.675e-2, 1.023e-1, 1.279e-1) | -0.5953340629 | |
| A9 | (6.745e-2, -1.048e-1, 1.279e-1, -5.160e-3, 1.828e-3) | -0.5953340629 | |
| A8 | (-5.166e-2, 1.237e-1, -6.739e-2, 1.580e-3, 9.526e-2, -7.994e-2) | -0.5953340629 | |
| A7 | (-4.792e-3, 2.473e-2, 2.473e-2, -1.108e-2, 2.110e-2) | -0.5953340629 | |
| A6 | (2.878e-2, 0, 2.878e-2, -2.133e-3) | -0.5953340629 | |
| A5 | (8.329e-2, -3.136e-1, 3.181e-1, 2.143e-1, -6.358e-2, -8.228e-2, 9.042e-3, -1.720e-1) | -0.5953340629 | |
| A4 | (5.811e-2, -7.592e-2, -3.640e-1) | -0.5953340629 | |
| A3 | (3.618e-4, -5.500e-3, 1.949e-3, 0) | -0.5953340629 | |
| A2 | (-4.091e-1, -7.335e-2, 7.925e-2, 1.565e-1, 3.951e-1, 7.340e-2, -7.624e-2, -1.536e-1) | -0.5953340629 | |
| A1 | 写入 19 个状态,最大为 0、1、9writes into 19 states, largest 0, 1, 9 | -0.5953340629 |
配对在每一行都相同:第一行是 ,最后一行是 。这就是点积恒等式 在一个 Gauss 点上逐条语句成立:每条反向语句都把配对从它的输出原样搬到它的输入上。哪一条写错(漏一项、少转置、= 写成 +=),从那一行起配对就变了。
The pairing is the same in every row: the first row is and the last . This is the dot-product identity holding statement by statement at one Gauss point: each reverse statement moves the pairing from its outputs to its inputs unchanged. A wrong statement (a missing term, a missing transpose, = instead of +=) changes the pairing from its row on.
核对:整个面 0(2 个 Gauss 点)的 与本页运算图的 faceVjp 相比,最大相对差为 ;每一行配对的相对变化不超过 ; 与 的相对差为 0(两者逐位相同)(B.1 实验对这个面显示的就是这两个数)(本页代码在构建时计算)
Check: of the whole face 0 (2 Gauss points) agrees with the page's graph faceVjp to a maximum relative difference of ; the pairing changes by at most a relative from row to row; and differ by a relative 0 (identical to the last digit) (the two numbers the lab of B.1 shows for this face) (computed by this page's code at build time)
第四步,固壁面与目标函数。在第 8 节第五步的固壁点上,把 方向合力的种子 反传: 给出 ,再逆序执行固壁语句:
Step 4, the wall face and the objective. At the wall point of Step 5 of Section 8, reverse the -force seed : gives , then run the wall statements in reverse order:
| 语句statement | 反向reverse | 产生的上横线bars produced | 值value | 配对pairing |
|---|---|---|---|---|
| W6 | (-2.563e-1, -2.736e-1, 0, 0) | 0.5997987307 | ||
| W5 | 同上面的 A8–A7, 换成 as A8–A7 above, with for | (2.563e-1, 9.571e-2, 0, 0, 6.834e-2, 1.914e-2, 1.914e-2, -3.417e-2, 0, 0) | 0.5997987307 | |
| W4 | (9.220e-2, 0) | 0.5997987307 | ||
| W3 | (5.676e-1, 4.798e-2, 0, 0) | 0.5997987307 | ||
| W2 | (-2.691e-1, -2.344e-1) | 0.5997987307 | ||
| W1 | 写入 16 个状态writes into 16 states | 0.5997987307 |
等温壁上 是设计变量:它从壁面压力 、物性 以及罚项 三处进入,所以 收集 与 两份(这里 );绝热壁上 , 改为加到 。残差种子 走的是同一串语句。
At an isothermal wall is a design variable entering three times, through the wall pressure , the transport and the penalty , so collects both and (here ); at an adiabatic wall and is added to instead. The residual seed runs through the same statements.
核对:面 32 的 与 faceVjp(残差种子为零、)相比为 ,配对逐行的相对变化不超过 ;残差种子 下同一个面的 与 faceVjp 相比为 (本页代码在构建时计算)
Check: of face 32 agrees with faceVjp (zero residual seed, ) to and the pairing changes by at most a relative from row to row; with the residual seed the of the same face agrees with faceVjp to (computed by this page's code at build time)
第五步,入口与牵引出口。入口点用规定状态 (第 1 节):对流通量 与黏性通量里的速度 、温度 都不含状态,状态只经罚项梯度 进入。前向与反向语句是:
Step 5, the inlet and the traction outlet. An inlet point uses the prescribed state (Section 1): the convective flux , the velocity in the viscous work and the temperature of the transport contain no state, which enters only through the penalty gradient . The tangent and reverse statements are:
其中 是第 3 节的六个面梯度在入口点上的值( 是它的 分量), 取在规定状态上;设计方向还有经 的一条路径:,再由 、 得到 。出口点只读线性迹 (一阶时为 ),总牵引 与热流 是数据(绕流 ),没有黏性路径:
with the six face gradients of Section 3 at the inlet point ( its part) and taken at the prescribed state; design directions add a path through : , and , give . An outlet point reads only the linear trace ( at first order); the total traction and heat flux are data (, in the flow), and there is no viscous path:
最后 经线性迹的权重 散射回邻域。
Finally is scattered back through the weights of the linear trace.
核对:六种配置下全部 576 个 Gauss 点(含入口 64、出口 64 个)逐点执行反向语句,逐面的 与 faceVjp 相比最大相对差为 ,每个点的配对在各条语句之间的相对变化不超过 ;各面的参数伴随(入口的 ,所有黏性点的 ,等温壁的 )与 faceVjp 的参数部分相比为 (本页代码在构建时计算)
Check: over the six configurations the reverse statements run at all 576 Gauss points (64 inlet and 64 outlet points among them); face by face agrees with faceVjp to a maximum relative difference of , the pairing of every point changes by at most a relative from statement to statement, and the parameter adjoints of every face ( at the inlet, at every viscous point, at an isothermal wall) agree with the parameter part of faceVjp to (computed by this page's code at build time)
图 9.2 把反向语句沿面循环倒着连起来:种子只放在单元 11 的能量行上(),再从最后一个面到第一个面逐面执行 A11–A1。只有与单元 11 相邻的面得到非零的 (48 个面中的 3 个),但每个这样的面都经转置的梯度邻域写回十几个状态,所以 的这一行(转置后)也延伸 5 圈,共 18 个非零块。前向时一列要 28 个面各贡献一份,反向时一行只要 3 个面、每个面写回许多状态:这就是“扇入”与“扇出”的对偶。
Figure 9.2 strings the reverse statements backwards along the face loop: the seed sits on the energy row of cell 11 only (); the faces run A11–A1 from the last to the first. Only faces next to cell 11 get a nonzero (3 of the 48 faces), but each writes back into a dozen or more states through the transposed gradient stencil, so this row of (transposed) also reaches 5 rings, 18 nonzero blocks in all. Forward, a column collects one contribution from each of 28 faces; in reverse, a row needs only 3 faces, each writing into many states: the duality of fan-in and fan-out.
前向方程求解
Tangent equation solve
选择一个物性参数,再选一个坐标方向。每次核对线性残差和目标方向导数。
Select a transport parameter, then a coordinate. Check the linear residual and objective directional derivative.
从隐函数得到状态响应
Obtain state response from the implicit equations
先解收敛流场,再固定该基态的线性化。零状态种子与单位设计方向 产生右端,解出的 再与同一设计方向一起送进目标求导。一个方向对应一次 tangent 求解;并非把任意扰动经过一次 JVP 就完成了敏感度求解。
First converge the primal, then fix its linearization. Zero state seed and a unit design direction form the right-hand side. Feed the solved and the same design direction into the objective derivative. One design direction requires one tangent solve; a single arbitrary JVP is not that solve.
本页几何方向只移动所选节点,其他节点固定;攻角导数按弧度。图中显示的是守恒量响应,若需要速度或温度响应,还要经过第 8 节的变量转换。
A coordinate direction moves just the selected node; other nodes are fixed. Incidence derivatives are per radian. Plotted responses are conserved quantities; velocity or temperature responses require the variable conversion of Section 8.
把“流场也随设计变化”写进链式法则
Include the design-induced state change
令一条设计路径为 。每个设计都对应重新收敛的状态。因为路径上的残差始终为零,对它求导仍应等于零:
Consider , with a converged state for each design. The residual remains zero along this path, so its derivative is zero:
这里假设当前离散 Jacobian 可逆,从而局部状态响应唯一。这是隐函数定理的有限维条件,不是对所有流动分支的全局唯一性保证。 的一次乘法只是给定 后求输出;求解 才是在找未知的 。
Assume the current discrete Jacobian is invertible so the local response is unique: a finite-dimensional implicit-function condition, not global uniqueness of all flow branches. Applying to a given differs from solving for an unknown .
这个例子只是线性代数练习,不是将 N–S 简化成两条物理方程。它让我们不依赖程序即可核对后面的符号和转置。真实页面有 96 个状态未知量,但推导完全相同。
This is a linear-algebra exercise, not a two-equation physical N–S model. It lets us check signs and transposes by hand. The real page has 96 state unknowns, with the same derivation.
逐步推导:设计右端项,逐列写出
Step by step: the design right-hand sides, one column at a time
前向方程的右端项是 ,沿参数方向()就是 。下面逐个参数写出列 ,也就是残差对该参数的显式导数(状态固定)。参数顺序与代码相同:,依次为入流幅度(绕流为 Mach 数)、入流方向、、、壁温(制造解中它把内边界的入口温度移动 )和热源幅度。
The right-hand side of the tangent equation is , which is along a parameter direction (). Below, each column is written out: the explicit derivative of the residual with respect to one parameter at fixed state. The order is the code's: are the inflow amplitude (Mach number in the flow case), the inflow direction, , , the wall temperature (in the manufactured case it shifts the inner-boundary inlet temperature by ) and the heating amplitude.
第一步,源项与热源幅度 (core.js 体循环中的 src)。能量方程的源项含 ,按体求积计算;残差减去源项,所以
Step 1, the source term and the heating amplitude (src in the volume loop of core.js). The energy source contains , computed by volume quadrature; the residual subtracts the source, so
源项不含状态,所以它对状态的线性化为零;对坐标的线性化就是第 12 节的 。制造解的源 在参数方向上保持固定。
The source does not contain the state, so its state linearisation is zero; its coordinate linearisation is of Section 12. The manufactured source stays fixed along parameter directions.
第二步,参考黏度 。、,所以应力与热流都与 成正比,黏性通量对它是一次齐次的:
Step 2, the reference viscosity . With and , stress and heat flux are both proportional to ; the viscous flux is homogeneous of degree one in it:
绝热壁的能量分量为零。制造解出口的数据 、 也用当前物性计算,给出 。
The energy entry vanishes at an adiabatic wall. The manufactured outflow data and also use the current transport and give .
第三步,Prandtl 数 。只有导热含 :,所以 ,而 :
Step 3, the Prandtl number . Only conduction contains : , so , and :
第四步,壁温 (物理固壁,等温)。 出现在壁面压力 、物性 和温度罚项中:
Step 4, the wall temperature (physical wall, isothermal). appears in the wall pressure , in the transport and in the temperature penalty:
绝热壁上 ,不含 ,这一列为零。
At an adiabatic wall does not contain , and the column is zero.
第五步,入流参数 (及制造解的 )。它们只通过规定的入流状态 起作用:绕流中 、;制造解中 、,内边界上 , 是基准值。由 得到
Step 5, the inflow parameters (and in the manufactured case). They act only through the prescribed inflow state : in the flow case and ; in the manufactured case , and, on the inner boundary, , where is the baseline. From ,
其中 是按第 3 节排列的六个 ( 是入口点上的六个面梯度),。这里的 Jacobian 都是第 3 节写出的那些,在规定状态 与修正梯度 处取值。出口数据不含这三个参数。
Here stacks the six as in Section 3 ( is the six face gradients at the inlet point) and . The Jacobians are those of Section 3, evaluated at the prescribed state and the corrected gradients . The outflow data do not contain these three parameters.
核对:六列 都按上面的公式手工组装,与本页运算图在单位参数种子下的 JVP 相比,在 6 种配置上最大相对差为 (本页代码在构建时计算)
Check: all six columns assembled by hand from these formulas agree with the page's graph JVP for a unit parameter seed over 6 configurations to a maximum relative difference of (computed by this page's code at build time)
逐步推导:用一次前向求解得到热源梯度
Step by step: one tangent solve for the heating gradient
取 (即 、),在默认制造解基准上解 (本页在构建时用 96 列 JVP 组装 ,再用部分主元 LU 求解)。图 10.2 画出右端项与温度响应。同一个导数可以走两条路:
Take (that is , ) and solve on the default manufactured baseline (the build assembles from 96 JVP columns and solves by partial-pivoting LU). Figure 10.2 shows the right-hand side and the temperature response. The same derivative can be formed by two routes:
若 只解到残差 ,两条路之差正好是第 11 节的 。两条路给出的都是基网格上离散目标的精确导数;第 12 节说明,这个基网格值与加密后的值相差很远,连符号都不同。
If is solved only to the residual , the two routes differ by exactly the Section 11 identity . Both routes give the exact derivative of the discrete objective on the base mesh; Section 12 shows that this base-mesh value is far from the refined one, even in sign.
核对:LU 解的相对残差为 ;前向路线得到 ,用存储的伴随解得到 ,两者之差 与 相符,剩余差别只有梯度的 倍(本页代码在构建时计算)
Check: the LU solve has relative residual ; the tangent route gives and the stored adjoint gives ; their difference matches , leaving times the gradient (computed by this page's code at build time)
伴随方程求解
Adjoint equation solve
伴随只需求解一次;切换显示分量时,解释它为何是残差行权重而非流场变量。
One adjoint solve suffices. When viewing components, explain why they weight residual rows rather than represent flow variables.
有限维 Lagrangian 与列梯度约定
Finite-dimensional Lagrangian and column gradients
所有 都是列梯度。 对应最终离散残差的行,包括被替换的边界约束行,它不是连续速度或温度伴随场。因为收敛时 ,乘子变化产生的项为零;令状态响应的系数为零即可消去 。同一目标只需一次伴随求解。用反向模式的话说:以 、 为种子把残差和目标反向执行一次,得到 ,同时得到设计伴随 ,也就是梯度。
All are column gradients. The multiplier weights final residual rows, including replaced boundary constraints; it is not a continuous velocity or temperature adjoint. At convergence removes the terms from a change of the multiplier. Cancel the state-response coefficient to eliminate , requiring one adjoint solve per objective. In reverse-mode terms: one reverse sweep of the residual and the objective seeded with and returns together with the design adjoint , the gradient.
右端、线性算子和目标必须属于同一阶数、同一边界、同一收敛状态。上式在精确 tangent 下说明:小伴随残差只有结合状态响应幅度才能解释为小梯度误差。
Right-hand side, operator and objective must use the same order, boundary and converged state. With an exact tangent, the identity shows why a small adjoint residual must be interpreted together with the response magnitude.
逐步消去未知状态响应
Eliminate the unknown state response step by step
目标方向导数 中最贵的项是 。从上一节的线性约束出发,把零乘以任意列向量 再加上,不改变总导数:
The expensive term in the objective tangent is . Starting from the linear constraint of Section 10, multiply the zero tangent constraint by any column and add it without changing the derivative:
选择 使 的系数为零,就不必为每个设计方向分别求 。这一步给出负号:本页使用“目标加残差乘子”的约定,所以右端是 。若改变 Lagrangian 的符号,乘子符号也要整体改变。
Choose to cancel the coefficient of , eliminating one response solve per design direction. The minus sign follows from adding the residual multiplier: the right-hand side is . A different Lagrangian sign would change the multiplier sign consistently.
在两未知量练习中,,,且没有目标显式项。直接手算可得:
In the two-unknown exercise, , , and the direct objective term is zero:
前向适合少量设计方向、多个目标;伴随适合一个或少量目标、很多设计变量。本页每个目标解一次伴随,即可返回六个物理参数与全部坐标的梯度。伴随向量的分量是残差行的权重,并不是速度或温度本身。
Tangents suit a few directions and many objectives; adjoints suit a few objectives and many design variables. One adjoint here yields six physical-parameter and all coordinate gradients. Its components weight residual rows, not physical velocities or temperatures.
图 11.1 画出物理绕流基准(等温壁、二阶、常物性)中 力目标的伴随解 的四个分量,每个分量有自己的色标。它们是残差行的权重: 表示残差第 块第 行的单位扰动使目标改变多少。本节的迭代动画则在加密网格上展示热流目标伴随解的能量分量怎样逐步形成。
Figure 11.1 shows the four components of the adjoint solution for the -force objective on the physical-flow baseline (isothermal wall, second order, constant transport), each with its own colour bar. They are residual-row weights: says how much a unit perturbation of row of residual block changes the objective. The iteration animation of this section shows, on a refined mesh, how the energy component of the heat-objective adjoint builds up.
逐步推导:为什么 的分量是行权重
Step by step: why the components of are row weights
第一步,在残差的第 行加上一个小量 ,即求解 , 是第 个单位向量。对 求导:
Step 1, add a small to residual row , i.e. solve with the -th unit vector. Differentiate with respect to :
第二步,代入目标的变化,并用伴随方程 :
Step 2, insert this into the change of the objective and use the adjoint equation :
所以 是“第 个方程多出一个单位残差”对目标的影响;它的单位是目标的单位除以该行残差的单位,不是速度或温度。
So is the effect on the objective of one extra unit of residual in equation ; its unit is the objective's unit divided by that row's residual unit, not a velocity or temperature.
核对:读入 run_enrichment.js 保存的 后,用本页运算图重新计算伴随真残差 ,基准流场残差 ;再用 JVP 组装的 做 LU,对 6 个残差行按第一、二步算出 ,与 的对应分量相比最大差为 的 倍(本页代码在构建时计算)
Check: after reading the stored by run_enrichment.js, the page's graph recomputes the true adjoint residual and the baseline flow residual ; with assembled from JVPs and factored by LU, from Steps 1 and 2 for 6 residual rows matches the corresponding components of to times (computed by this page's code at build time)
逐步推导:54 次前向求解,或 1 次伴随求解
Step by step: 54 tangent solves, or one adjoint solve
梯度有 54 个分量(6 个物理参数和 48 个坐标),编号从 0 起,与 、 一致: 是 , 与 是顶点 的 、 坐标。前向路线对每个分量 求一次线性方程,再做一次点积:
The gradient has 54 components (6 physical parameters and 48 coordinates), numbered from 0 like and : is , and , are the and coordinates of vertex . The tangent route solves one linear system per component and takes one dot product:
伴随路线只解一次转置方程,再做一次反向扫描:,。两条路线逐分量相等,因为
The adjoint route solves one transposed system and runs one reverse sweep: , . They agree component by component because
图 11.2 在物理绕流基准、 方向合力上把两条路线并排播放:前向每解一个方程只多知道一个分量,伴随解一个方程就得到全部。用本页的无矩阵 FGMRES(ASM/ILU(1) 预处理,容差 )实际求解,54 个前向方程共迭代 1823 次(每个 33–37 次),伴随方程迭代 36 次;每次迭代是一次 JVP 或 VJP 加一次预处理。
Figure 11.2 plays the two routes side by side for the force on the physical-flow baseline: each tangent solve adds one component, one adjoint solve gives them all. With this page's matrix-free FGMRES (ASM/ILU(1) preconditioner, tolerance ) the 54 tangent systems take 1823 iterations in all (33–37 each), the adjoint system 36; each iteration is one JVP or VJP plus one preconditioner application.
核对:构建时用 96 个 JVP 组装 、做一次 LU、回代 54 次得到的 与伴随梯度相比,最大相对差为 (每个回代的相对残差不超过 );迭代次数来自 run_enrich_ta.js 用同一组求解器的实际求解(本页代码在构建时计算)
Check: the from assembling with 96 JVPs, one LU factorisation and 54 back substitutions agree with the adjoint gradient to a maximum relative difference of (relative residual of every back substitution at most ); the iteration counts are actual solves with the same solver by run_enrich_ta.js (computed by this page's code at build time)
逐步推导:伴随方程的右端 :温度跟踪目标
Step by step: the right-hand side of the adjoint equation: temperature tracking
默认目标是体积分 ,,其中 是对流用的线性迹(一阶时为 )。以 逆序执行:
The default objective is the volume integral with , the linear trace of convection ( at first order). With , in reverse order:
得到的 就是 ;几何部分另有 与 ,交给第 12 节的几何反向。跟踪目标不含物理参数,。
The resulting is ; the geometry receives and for the geometric reverse of Section 12. The tracking objective contains no physical parameter, so .
核对:在默认制造解基准上(224 个体积求积点,),这些语句给出的 与本页反向扫描(,)相比最大相对差为 ,反向扫描给出的 最大绝对值为 (本页代码在构建时计算)
Check: on the default manufactured baseline (224 volume quadrature points, ) these statements give within a maximum relative difference of of the page's reverse sweep (, ), whose has largest magnitude (computed by this page's code at build time)
下面的动画播放加热壁面层流算例(第 7 节的图)已保存的实际迭代:原始 Newton 的 4 步温度与速度,以及热流目标伴随的 46 次 FGMRES 迭代。伴随从零开始。ASM/ILU 预处理本身是非局部的,所以第一次迭代就已远离壁面:紧邻外边界的 32 个控制体中有 29 个超过该次迭代最大值的 1%。但权重集中在壁面附近:半径小于 0.9 的控制体占 的 70%,最终解中为 38%。第 8 次迭代以后,归一化的场形状与最终解相差不到 1%;其余迭代只把真残差降到容差以下。
The animation below plays stored actual iterates of the heated-wall laminar case (the Section 7 figure): 4 primal Newton steps of temperature and velocity, and 46 FGMRES iterations of the heat-objective adjoint. The adjoint starts from zero. The ASM/ILU preconditioner is itself non-local, so the first iterate already reaches far from the wall: 29 of the 32 volumes next to the outer boundary exceed 1% of that iterate's maximum. Its weight, however, sits near the wall: volumes with centroid radius below 0.9 hold 70% of , against 38% in the final solution. From iteration 8 on, the normalized field differs from the final one by less than 1%; the remaining iterations only drive the true residual below the tolerance.
参数与几何导数
Parameter and geometry derivatives
移动坐标方向只在图中标出单位方向,不改变当前基准。对比目标显式项和残差响应项。
The coordinate arrow denotes a unit direction, without deforming the baseline. Compare direct and residual-response terms.
物性、边界与源项参数
Transport, boundary and source parameters
参数依次为来流幅度、方向、参考黏度、Pr、壁温/制造解内边界温度偏移、附加热源。制造解的前两个参数分别扰动入口两个速度分量;基准解析源项固定,不随这些局部扰动重新制造。制造解出口牵引与热流使用当前物性和固定解析场,因此物性也有显式边界路径。
Parameters are inflow amplitude, direction, reference viscosity, Pr, wall temperature/manufactured inner-boundary temperature offset, and added heating. In the manufactured case the first two perturb inlet velocity components; analytic baseline forcing stays fixed. Manufactured outflow traction and heat use current transport and a fixed analytic field, retaining explicit transport boundary paths.
坐标导数贯穿整条离散链
Coordinates enter the entire discrete chain
是长度法向。坐标还改变体积、质心、中心矩、边界距离和目标求积。固定制造源函数不等于固定其空间采样值。没有引入网格弹性映射,所以表中的节点梯度是直接坐标梯度,不是已包含内部网格移动的完整物面形状梯度。
The length normal is . Coordinates also change volume, centroid, moments, boundary distances and objective quadrature. Freezing the manufactured source function does not freeze its spatial samples. With no mesh-elasticity map, these are direct coordinate gradients, not complete body-shape gradients including interior mesh motion.
单位法向的导数为什么不能省略归一化
Differentiate the normalization of a unit normal
最后一个恒等式来自 ,可以作为实现的局部检查。移动节点还会改变求积点、体积和拟合中心。例如有向多边形的面积可由相邻顶点的叉积求和,再直接对每一项求导:
The final identity follows from and is a local check. Node motion also changes quadrature, volume and fit centres. For example, differentiate the signed polygon-area formula term by term:
若将来用少量物面参数 控制全部网格坐标 ,还需要一层链式法则 。当前页面直接扰动节点坐标,尚未引入网格变形求解器。
A future mesh map from a few shape parameters needs another chain rule, . This page directly perturbs coordinates and has no mesh-deformation solver.
图 12.2 把 力目标对全部 48 个坐标的梯度画成顶点上的箭头:左图是显式项 ,右图是总梯度 。图 12.3 画出移动一个顶点时哪些几何量跟着移动。
Figure 12.2 draws the gradient of the -force objective with respect to all 48 coordinates as arrows at the vertices: the explicit term on the left and the total gradient on the right. Figure 12.3 shows which geometric quantities move when one vertex moves.
逐步推导:面积、形心与二阶矩对坐标的导数
Step by step: coordinate derivatives of area, centroid and second moments
第一步,用顶点写出多边形积分。令 (原点与边 组成的有向三角形面积),由散度定理
Step 1, write the polygon integrals through the vertices. With , the signed area of the triangle formed by the origin and edge , the divergence theorem gives
把 换成 得到其余两个积分。
Exchanging and gives the remaining two integrals.
第二步,形心与中心矩是它们的商与差:
Step 2, the centroid and central moments are quotients and differences of these:
第三步,逐项求方向导数。先写出每个有向三角形面积的方向导数,再用乘积与商法则:
Step 3, differentiate term by term along the direction: first the signed triangle areas, then the product and quotient rules give
这些量进入拟合行 、权重 与边界距离 ,再经 进入每个迹。
These quantities enter the fit rows , the weights and the boundary distance , and through every trace.
核对:对全部 24 个单元,第一、二步的封闭公式与 7 点求积值相同,第三步的导数与 geometry.js 运算图导数相同,最大相对差为 ,共 168 项(本页代码在构建时计算)
Check: for all 24 cells the closed forms of Steps 1 and 2 equal the 7-point quadrature values and the derivatives of Step 3 equal the graph derivatives of geometry.js, to a maximum relative difference of over 168 items (computed by this page's code at build time)
逐步推导:几何的反向:从法向、长度与求积点回到顶点坐标
Step by step: the reverse of the geometry: from normals, lengths and quadrature points back to the vertex coordinates
上面各步是几何的前向。反向时,残差和目标的反向扫描先在几何量上留下上横线(,面积、形心与二阶矩 ),再逆序经过 geometry.js 的语句回到顶点坐标 。一段子面从 到 、朝向 :
The steps above are the forward geometry. In reverse, the sweep of the residual and objective leaves bars on the geometric quantities ( and area, centroid and second moments ), which then run backwards through the statements of geometry.js to the vertex coordinates . For a segment from to with orientation :
单元页的 48 段子面的端点都是顶点。边界距离 (形心到边界点的法向距离)给出 、、;拟合权重 ( 为形心):
On the cell page the ends of all 48 segments are vertices. The boundary distance (the normal distance from the centroid to the boundary point) gives , and ; the fit weights ( the centroids):
多边形积分(上面第一步的公式)逆序。 在 之后才算、并且读取它,所以先反传:、 等;这样 才是完整的,再传给 :、 等;再对每条边:
The polygon integrals (Step 1 above) in reverse. is computed after and reads it, so it is reversed first: , and so on; only then is complete and passed on to : , and so on; then, edge by edge:
再加上 等直接依赖顶点坐标的项。拟合矩阵 、基函数行 与迹的位置也依赖坐标,它们的反向由第 9 节的 开始,经 回到 与 。
plus the terms and so on that depend on the vertex coordinates directly. The fit matrix , the basis rows and the trace positions also depend on the coordinates; their reverse starts from of Section 9 and returns through to and .
核对:给全部 928 个几何输出(每段子面的 ,面积、形心、二阶矩,每个边界 Gauss 点的 ,每对邻居的 )任意的上横线,这些语句得到的 与 geometry.js 运算图的 VJP 相比最大相对差为 (本页代码在构建时计算)
Check: with arbitrary bars on all 928 geometric outputs (the of every segment, the areas, centroids and second moments, at every boundary Gauss point and for every pair of neighbours) these statements give within a maximum relative difference of of the VJP of the graph of geometry.js (computed by this page's code at build time)
图 12.4 用重新收敛的有限差分检验整条链:把每个固壁顶点沿指向流体的法向 移动 (,Newton 收敛到 ),中心差分 与伴随梯度的投影 比较。
Figure 12.4 tests the whole chain with reconverged finite differences: every wall vertex is moved by along its normal into the fluid (, Newton converged to ), and the central difference is compared with the projection of the adjoint gradient.
核对:8 个固壁顶点、两个目标( 方向合力与壁面热流)的伴随投影与重新收敛的中心差分相比,差值不超过各自最大值的 (本页代码在构建时计算)
Check: over the 8 wall vertices and both objectives ( force and wall heat) the adjoint projections agree with the reconverged central differences to within of the largest value of each objective (computed by this page's code at build time)
基网格梯度不等于连续梯度
A base-mesh gradient is not the continuum gradient
读表时注意:这些是 24 个控制体基网格上的离散梯度。它们是这个离散目标的精确导数(第 13、14 节用差分与复数步长核对),却不一定接近连续问题的梯度。以热源幅度为例,附录 A.18 对同一问题(同一制造基态、物性和温度跟踪目标)逐级加密:格心二阶在基网格上为 ,第 1 级加密(96 个控制体)就变为 ,到 6144 个控制体为 ;独立连续参考为 。粗网格连梯度符号都给错了,因此不能拿基网格的数值去判断设计方向。
Read the table with care: these are discrete gradients on the 24-volume base mesh. They are exact derivatives of this discrete objective (checked by differences and complex steps in Sections 13 and 14) but need not be close to the continuum gradient. Take the heating amplitude: Appendix A.18 refines the same problem (same manufactured base, transport and tracking objective). Second-order cells give on the base mesh, at refinement level 1 (96 volumes) and at 6144 volumes; the independent continuous reference is . The coarse mesh even has the wrong sign, so base-mesh values must not be used to judge a design direction.
壁温/内边界温度(参数 4)在同一基网格上,格心为 ,格点为 ,符号相反。用同样的稀疏精确伴随逐级加密(run_boundary_temperature.js):格心依次为 、、、、,格点依次为 、、、、,最细层(6144/4224 个控制体)两者相差 0.15%。两种格式趋于同一个正值,所以格心的基网格值更接近加密极限,格点基网格的负号是粗网格误差。它并不只来自纯入口节点 0、1、7 的强温度行:把 的第 4 分量按行拆开,基网格上强约束行贡献 ,其余平衡行(入口半面的规定状态与梯度惩罚)贡献 ,二者同号;加密后强约束行的贡献变为正值(最细层 ),平衡行贡献缩小到 。
For the wall / inner-boundary temperature (parameter 4) the same base mesh gives for cells and for nodes, with opposite signs. Refining with the same sparse exact adjoint (run_boundary_temperature.js) gives, for cells, , , , , and, for nodes, , , , , ; on the finest level (6144 / 4224 volumes) they differ by 0.15%. Both schemes approach the same positive value, so the cell base-mesh value is closer to the refined limit and the node's negative sign is coarse-mesh error. It does not come only from the strong temperature rows at pure-inlet nodes 0, 1, 7: splitting component 4 of row by row, strong rows contribute on the base mesh and the remaining balance rows (prescribed inlet states and gradient penalties) contribute , with the same sign. On refinement the strong-row part turns positive ( on the finest level) and the balance-row part shrinks to .
坐标行也要这样读。以内部顶点 X[12] 的 x 坐标为例,目标显式项为 ,残差响应项为 ,总导数为 ,只有显式项的 3.7%。移动内部顶点不改变连续问题的区域和数据,只改变离散方式,所以连续目标对这种移动的导数恰为零,离散导数只是离散误差随顶点位置的变化。显式项(存储状态不动,求积点、权重和重构随顶点移动)与响应项(存储状态跟着网格移动)各自相当于把局部流场平移了一下,本身并不小;二者相加只剩离散误差的变化,这部分随网格加密趋于零。移动固壁或入口上的顶点则不同:它改变区域形状,在连续极限下仍有非零导数。
Read the coordinate rows the same way. For the x coordinate of interior vertex X[12], the direct objective term is , the residual-response term , and the total , only 3.7% of the direct term. Moving an interior vertex changes neither the continuum domain nor its data, only the discretization, so the continuum objective has exactly zero derivative for such a motion, and the discrete derivative is only the change of the discretization error with the vertex position. The direct term (stored states fixed while quadrature points, weights and reconstruction move with the vertex) and the response term (stored states following the mesh) each amount to shifting the local field and are not small by themselves; their sum leaves only the change of the discretization error, which vanishes under refinement. Moving a vertex on a wall or inlet is different: it changes the domain and keeps a nonzero derivative in the continuum limit.
有限差分与 Taylor 检验
Finite differences and Taylor checks
选择一个方向并扫描步长;检查正负扰动残差后,再解释误差下降与回升。
Sweep steps for one direction. Inspect both perturbed residuals before interpreting the error curve.
每个步长都重新收敛
Reconverge for every step
可以选择全部六个物理参数及所有节点的 x/y 坐标,执行一个步长或完整步长扫描。每次重新构造几何、边界和流场。大步长有截断误差,小步长受浮点相消和原始容差影响;误差接近零时优先查看绝对误差。全部方向扫描较慢,计算中显示进度。
Select all six physical parameters and x/y coordinates of every vertex, for one step or a complete step sweep. Rebuild geometry, boundaries and primal flow each time. Large steps incur truncation error and small steps suffer cancellation and primal tolerance. Use absolute error near zero derivatives. All-direction sweeps are slower and display progress.
随页保存的差分与 Taylor 结果
Saved difference and Taylor results
下面的数据由 run_static_evidence.js 在默认基准(格心、二阶、常物性、温度跟踪)上用本页代码算出;按钮仍可在浏览器中重算。每个步长都把正负扰动的流场重新收敛到残差 以下,全部 48 个扰动解的最大终止残差为 。
These data were computed by run_static_evidence.js with this page's code on the default baseline (cell, second order, constant transport, temperature tracking); the buttons still recompute them in the browser. For every step both perturbed flows are reconverged below residual ; the largest final residual among all 48 perturbed solves is .
| 方向Direction | 伴随梯度 gAdjoint gradient g | 单边斜率(10⁻²→10⁻⁴)One-sided slope (10⁻²→10⁻⁴) | 中心斜率(10⁻¹→10⁻³)Central slope (10⁻¹→10⁻³) | E₂ 斜率(10⁻²→10⁻⁴)E₂ slope (10⁻²→10⁻⁴) | 最小中心相对误差Smallest central relative error | 对应 εat ε |
|---|---|---|---|---|---|---|
| 参考黏度 μ_refreference viscosity μ_ref | -5.111e-3 | 0.99 | 2.04 | 1.99 | 1.093e-8 | 1.000e-5 |
| 热源幅度heating amplitude | 6.739e-4 | 1.00 | 2.00 | 2.00 | 1.100e-8 | 1.000e-4 |
| X[12].xX[12].x | -1.329e-5 | 1.00 | 2.03 | 2.00 | 5.726e-7 | 1.000e-4 |
应期待的吻合程度:截断误差区的斜率接近 1(单边)或 2(中心差分与 );最小相对误差由截断误差与求解、舍入误差的平衡决定,上表三个方向为 到 ,出现在 到 。再减小步长时,目标值的求解误差 被放大为 ,曲线回升;这部分不说明伴随有误。这些检查只说明伴随给出了本离散目标的精确导数;粗网格上的离散梯度本身仍可能与连续梯度相差很大,甚至符号相反(第 12 节,附录 A.18)。
What agreement to expect: in the truncation range the slopes approach 1 (one-sided) or 2 (central and ). The smallest relative error is set by the balance of truncation against solve and roundoff error; for the three directions above it ranges from to at from to . For smaller steps the objective solve error is amplified to and the curve turns up; this does not indicate an adjoint error. These checks show only that the adjoint gives the exact derivative of this discrete objective; the discrete gradient on a coarse mesh can still differ greatly from the continuum gradient, even in sign (Section 12, Appendix A.18).
由 Taylor 展开预测差分误差
Predict difference errors using Taylor expansion
设计 同时包含物理参数与坐标,约化目标为 。在可微的固定分支上沿单位方向展开:
The design holds both the physical parameters and the coordinates; write the reduced objective . Expand along a unit direction within a smooth fixed branch:
正负相减消掉二次项,所以中心差分的截断误差通常比单边差分低一阶。但若每次目标求解带有误差 ,除以步长会放大到 ,因此图线不会随步长缩小而永远下降。曲线回升不一定是伴随写错,也可能是流场容差与浮点相消。
Subtracting opposite perturbations cancels the quadratic term, improving central truncation error by one order. However, objective-solve error contributes . Thus the curve need not decrease indefinitely; an upturn may reflect solve tolerance or cancellation rather than an adjoint error.
若只在固定 U 上扰动参数,算到的是显式偏导 ,不是经过重新收敛的总导数。操作时先选一个坐标方向,查看不同步长、正负流场残差与伴随值,再切换到全部方向。
Perturbing parameters at fixed U yields only the direct partial derivative , not the reconverged total derivative. Start with one coordinate, inspect step sizes, both perturbed residuals and the adjoint value, then switch to all directions.
独立复数算术
Independent complex arithmetic
用与差分相同的方向,比较独立复数运算和伴随导数。
Compare independent complex arithmetic and adjoint derivatives in the same direction as finite differences.
复数残差是另一条算术路径
Complex residuals use another arithmetic path
从收敛实解开始,用复数四则运算、正值分支的分数幂和三角函数评估同一残差,再校正虚部。整数幂用代数乘法,避免对负实数做极坐标幂时引入与步长无关的虚部舍入。此路径不直接返回 tangent 结果,但校正线性系统仍复用实 Jacobian。
Start from the converged real state, evaluate the residual using complex arithmetic, positive-branch fractional powers and trigonometric functions, then correct its imaginary part. Integer powers use algebraic multiplication to avoid step-independent imaginary roundoff from polar powers of negative reals. The final result is not copied from a tangent, although corrections reuse the real Jacobian.
几何拓扑、边界分区和拟合邻域保持固定。有效性检查在实基态进行;不对复数作大小比较、绝对值 limiter 或裁剪。必须同时查看缩放虚部残差,而不是仅凭步长很小判断正确。
Topology, boundary partitions and fit stencils stay fixed. Admissibility is checked at the real base state, without complex ordering, absolute-value limiters or clipping. Inspect scaled imaginary residuals rather than trusting a small step by itself.
随页保存的复数步长结果
Saved complex-step results
默认基准上,本页的独立复数残差对六个物理参数和三个坐标方向(步长 )给出的梯度,与伴随梯度的最大相对差为 ,缩放虚部残差 都小于 。复数步长没有相减,截断误差为 ;步长足够小后,差值停在与步长无关的平台 ;它来自参照值 本身的伴随求解容差(FGMRES 解到 ),不是复数步长的误差:同一复数步长值与用 LU 精确求解伴随得到的梯度之差要小一个数量级(check_enrich_ta.js 核对)。
On the default baseline, this page's independent complex residual gives gradients for the six physical parameters and three coordinates (step ) that differ from the adjoint gradient by at most relative; every scaled imaginary residual is below . With no subtraction the truncation error is ; once the step is small enough the difference stays on a step-independent plateau of ; it comes from the adjoint-solve tolerance of the reference (FGMRES to ), not from the complex step: the same complex-step value is an order of magnitude closer to the gradient of an LU-exact adjoint solve (checked by check_enrich_ta.js).
| 方向Direction | 复数步长Complex step | 伴随Adjoint | 相对差Relative difference | ‖Im R/ε‖‖Im R/ε‖ |
|---|---|---|---|---|
| 来流幅度/入口 x 速度inflow amplitude / inlet x velocity | 1.886e-3 | 1.886e-3 | 3.370e-10 | 5.334e-12 |
| 方向参数direction parameter | 3.642e-4 | 3.642e-4 | 6.121e-9 | 8.089e-12 |
| 参考黏度 μ_refreference viscosity μ_ref | -5.111e-3 | -5.111e-3 | 1.162e-10 | 7.327e-12 |
| Prandtl 数 PrPrandtl number Pr | 1.378e-3 | 1.378e-3 | 2.535e-10 | 4.714e-12 |
| 壁温/内边界温度wall / inner-boundary temperature | 7.168e-3 | 7.168e-3 | 4.563e-9 | 8.885e-12 |
| 热源幅度heating amplitude | 6.739e-4 | 6.739e-4 | 6.995e-9 | 3.831e-12 |
| X[0].xX[0].x | 7.770e-5 | 7.770e-5 | 8.561e-10 | 5.373e-12 |
| X[12].xX[12].x | -1.329e-5 | -1.329e-5 | 3.314e-8 | 2.460e-12 |
| X[12].yX[12].y | 5.745e-5 | 5.745e-5 | 2.555e-9 | 5.161e-12 |
复数步长为何不需要相减
Why complex step avoids subtraction
它通过虚部隔离一次项,避免两个接近实数的目标值相减。但前提是整条残差计算在当前分支上能作解析延拓;例如使用绝对值直接处理复数,就会破坏这条展开。这里使用独立复数残差、固定拓扑及固定分支。
The imaginary part isolates the first-order term without subtracting nearby real objective values. The residual path must admit analytic continuation on the active branch; complex absolute values would invalidate the expansion. This implementation uses independent complex arithmetic and fixed topology and branches.
因此实 Jacobian 可以用来校正缩放虚部残差,但最终目标虚部仍必须由复数运算独立算出。两种方法共用物理残差定义,所以通过复数检查仍不能单独证明物理模型和边界条件正确。
The real Jacobian can therefore correct the scaled imaginary residual, while the final imaginary objective is evaluated independently. Both routes share the residual definition, so agreement alone cannot prove physical modelling and boundary correctness.
制造解与空间精度
Manufactured solution and spatial accuracy
比较一阶与二阶的误差比;分清网格尺寸与设计差分步长。
Compare first/second-order error ratios and distinguish mesh size from the design-difference step.
独立制造源项与固定几何域
Independent forcing on a fixed geometric domain
源项通过解析函数的空间双层方向运算构造,包含应力散度、黏性功和变物性导数,不调用离散残差生成源项。四个分量都允许非零源。加密细分同一个多边形域,边界中点留在原直边上,避免把物面形状改变混进阶数。
Spatial nested directional arithmetic differentiates analytic functions to construct forcing, including stress divergence, viscous work and variable transport. It does not manufacture forcing from the discrete residual. All four source components may be nonzero. Refinement subdivides the same polygonal domain, placing boundary midpoints on existing straight edges rather than changing the body shape.
格心参考为独立高阶求积的守恒平均,格点参考为解析节点值。表中给出实际误差和观测斜率;“二阶重构”是算法选择,不是预先保证每个目标、壁面应力和粗网格误差都二阶。物理绕流的多边形角点与薄层解析能力另当别论。
Cell references are conservative averages from independent high-order quadrature; node references are analytic point values. Tables report measured errors and rates. Second-order reconstruction does not pre-guarantee second order for every objective, wall stress or coarse-grid error. Polygonal corners and boundary-layer resolution in physical flow are separate issues.
制造解检查的对象与二阶误差的含义
What manufactured solutions and second-order errors test
选好光滑的 后,先代入连续物理通量并求散度,得到独立源项 。边界数据也由同一个解析场构造。然后求解离散方程并比较;若用离散残差本身来构造“源项”,就失去了检查空间离散误差的独立性。
Choose smooth , differentiate its physical flux to construct independent , and derive boundary data from that field. Solve the discrete problem and compare. Manufacturing a source from the discrete residual itself would remove this independent spatial-error check.
这说明误差每次约缩小四倍才对应二阶,而不是“图看上去更光滑”。这里 h 表示网格加密尺度;第 13、14 节的 表示设计方向上的差分或复数步长,两者不能混在同一结论中。格心误差必须比较守恒平均:一般 不等于 。
Second order means roughly fourfold error reduction per halving of h, not simply a smoother picture. Mesh scale h differs from the design step of Sections 13 and 14. Compare conserved averages for cells: generally .
无滑移壁面的制造解
A no-slip manufactured solution
上面的制造解在内圈使用开口的入流/牵引数据,从不经过第 4 节的固壁代码。为了检验真实的固壁闭合——格心的面通量与惩罚梯度(系数 2)、格点的强速度行、等温温度行和绝热法向梯度行,以及第 7 节的壁面合力与热流——另造一个在内圈满足无滑移的光滑解。内圈走与层流绕流完全相同的固壁分支(core.js 的 wallmms 情形),外圈仍用本节的入流/牵引闭合和数据。
The manufactured solution above uses open inflow/traction data on the inner boundary and never reaches the wall code of Section 4. To test the actual wall closures — the cell face flux with its penalty gradient (factor 2), the node strong velocity rows, the isothermal temperature row and the adiabatic normal-gradient row, and the wall force and heat outputs of Section 7 — build a smooth solution that satisfies no-slip on the inner polygon. The inner boundary takes exactly the wall branch of the laminar-flow case (core.js mode wallmms); the outer boundary keeps this section's inflow/traction closures and data.
其中 是到内圈第 条边所在直线的有符号距离(流体一侧为正),,带星号的量是本节开头的解析场, 表示截断到 (用 写成,所以复数步长和自动微分都能直接通过)。内圈最远的点半径为 ,那里 、,于是 、;绝热版本还有 ,因此 。外圈最近的点半径为 ,那里 ,边界数据就是原来的开口制造场。 在两端三阶导数连续,源项仍由解析通量求散度得到。每条壁边上的切向剪应力和(等温时)法向热流都不为零(check_wall_mms.js 逐边核对),所以固壁代码的每一项都参与计算。精确的壁面合力与热流用每条边 12 点 Gauss 积分:等温时 ,;绝热时 。
Here is the signed distance to the line of inner edge (positive in the fluid), , starred quantities are the analytic field at the start of this section, and clips to (written with , so complex steps and automatic differentiation pass straight through). The farthest inner-boundary point has radius , where and , hence and ; the adiabatic variant also has and so . The nearest outer-boundary point has radius , where and the boundary data are the original open manufactured field. is three times continuously differentiable at both ends, and the source is again the divergence of the analytic flux. The wall shear stress and, for the isothermal case, the normal heat flux are nonzero on every wall edge (check_wall_mms.js checks each edge), so every term of the wall code is exercised. Exact wall loads use 12-point Gauss integration per edge: isothermal , ; adiabatic .
网格族仍是同一张粗网格的均匀细分,边界中点留在原直边上,所以每一级的内圈都是同一个多边形,制造场在每一级都精确满足壁面条件。状态误差沿用上面的体积加权范数,观测阶取相邻两级误差比的以 2 为底对数。壁面合力 与热流 是第 7 节目标使用的同一离散泛函;绝热时另外比较壁面迹温度与 的 误差。第 0 级(24 个控制体)对这个解太粗:格心二阶·等温和格心二阶·绝热收敛后的第 0 级解在外边界某个面上的带符号法向速度为负(最小裕量 ),求解按设计报告“离开支持分支”,这一级不计入;其余格式的第 0 级也不用于观测阶。
The family is again uniform subdivision of the one coarse mesh; boundary midpoints stay on the original straight edges, so every level has the same inner polygon and the manufactured field satisfies the wall conditions exactly on every level. The state error uses the volume-weighted norm above, and observed orders are base-2 logarithms of successive error ratios. The wall force and heat are the same discrete functionals as the Section 7 objectives; adiabatic cases also compare the wall trace temperature with in . Level 0 (24 volumes) is too coarse for this field: for cell 2nd · iso and cell 2nd · adi the converged level-0 solution has a negative signed normal velocity on an outer-boundary face (smallest margin ), the solver reports leaving the supported branch as designed, and that level is excluded; level 0 of the other schemes is not used for observed orders either.
| 格式Scheme | 第 1 级 E_hlevel 1 E_h | 第 2 级 E_hlevel 2 E_h | 第 3 级 E_hlevel 3 E_h | 第 4 级 E_hlevel 4 E_h | 状态观测阶State order | 合力观测阶Force order | 热流/壁温观测阶Heat / wall-T order |
|---|---|---|---|---|---|---|---|
| 格心一阶·等温cell 1st · iso | 8.311e-2 | 6.857e-2 | 4.691e-2 | 2.804e-2 | 0.74 | 1.05 | 1.24 |
| 格心一阶·绝热cell 1st · adi | 8.313e-2 | 6.946e-2 | 4.727e-2 | 2.814e-2 | 0.75 | 1.04 | 0.83 |
| 格心二阶·等温cell 2nd · iso | 9.776e-2 | 3.767e-2 | 1.326e-2 | 3.432e-3 | 1.95 | 2.22 | 1.46 |
| 格心二阶·绝热cell 2nd · adi | 9.940e-2 | 3.797e-2 | 1.329e-2 | 3.438e-3 | 1.95 | 2.22 | 1.48 |
| 格点一阶·等温node 1st · iso | 1.073e-1 | 7.798e-2 | 5.402e-2 | 3.584e-2 | 0.59 | 0.85 | 0.44 |
| 格点一阶·绝热node 1st · adi | 1.206e-1 | 7.862e-2 | 5.425e-2 | 3.597e-2 | 0.59 | 0.83 | 0.89 |
| 格点二阶·等温node 2nd · iso | 1.201e-1 | 4.661e-2 | 1.744e-2 | 5.742e-3 | 1.60 | 1.74 | 1.66 |
| 格点二阶·绝热node 2nd · adi | 1.279e-1 | 4.690e-2 | 1.751e-2 | 5.764e-3 | 1.60 | 1.62 | 2.83 |
观测阶取最细一对网格。二阶格心:状态 1.95/1.95(等温/绝热),合力 2.22/2.22;二阶格点:状态 1.60/1.60,合力 1.74/1.62。等温热流为格心 1.46、格点 1.66,绝热壁温为格心 1.48、格点 2.83。一阶格心:状态 0.74/0.75,合力 1.05/1.04;一阶格点:状态 0.59/0.59,合力 0.85/0.83。二阶格心的状态和合力在最细一对网格上已接近设计的二阶;二阶格点的状态观测阶在最后两对网格上为 1.42、1.60(等温),仍在上升,尚未到达二阶。等温热流的有符号误差 在较粗几级不单调:格心二阶依次为 、、、,格点二阶依次为 、、、。一阶格式的状态观测阶仍明显低于 1。这些量尚在前渐近区,页面不据此宣称它们已达到设计阶数。
Observed orders use the finest pair of meshes. Second-order cells: state 1.95 / 1.95 (isothermal / adiabatic), force 2.22 / 2.22; second-order nodes: state 1.60 / 1.60, force 1.74 / 1.62. Isothermal heat flux gives 1.46 for cells and 1.66 for nodes; adiabatic wall temperature 1.48 and 2.83. First-order cells: state 0.74 / 0.75, force 1.05 / 1.04; first-order nodes: state 0.59 / 0.59, force 0.85 / 0.83. Second-order cell state and force are close to the design order two on the finest pair; the second-order nodal state order over the last two pairs is 1.42, 1.60 (isothermal), still rising and not yet two. The signed isothermal heat-flux errors are not monotone on the coarser levels: , , , for second-order cells and , , , for second-order nodes. First-order state orders remain clearly below one. These quantities are still pre-asymptotic, and the page does not claim design order for them.
连续对照与验证范围
Continuum comparison and scope
区分转置点积、总梯度、空间精度与连续参照四类检查。
Distinguish transpose products, total gradients, spatial accuracy and continuum checks.
三个不同层次的正确性
Three different levels of correctness
点积测试检查转置;全重算差分检查实际离散目标的总导数;制造解加密检查离散解对连续方程的逼近。它们不是同一个检查,也不能互相替代。连续参考的独立性应来自不同方程/边值求解路线,而不是换一个符号调用同一矩阵。
Dot tests check transposition; reconverged differences check total derivatives of the actual discrete objective; manufactured refinement checks approximation of the continuum equation. These are distinct tests. Independence requires a different equation/boundary-solve route, not renaming the same matrix.
将积分残差除以体积会改变乘子数值,但在收敛状态不改变总梯度。强边界行需要自己的尺度。正文 与附录的连续 不直接逐点相减;附录 A 提供完整二维推导、O 型域上独立的二维边值求解与制造目标梯度验证,以及平面一维 ODE 对照。温度跟踪另有局部 P₁/P₂ 连续弱形式、局部加密与独立原始重算;同网格差分吻合与连续网格精度分别报告。A.18 进一步把正文四种离散格式的热源梯度与同一问题的连续参考直接加密比较。有限网格差距和参考自身的变化分别展示;这项验证限定为开口内圈热源问题,不能推广到一般二维绕流。
Dividing integrated residuals by volume changes multiplier values but not converged total gradients. Strong boundary rows need their own scales. Do not subtract the discrete directly from continuum . Appendix A provides the full 2D derivation, an independent O-domain BVP solve and manufactured-objective gradient validation, plus a plane-1D ODE comparison. Tracking additionally has a local P₁/P₂ continuous weak form, local refinement and independent primal reruns. Same-mesh FD agreement and continuum resolution are reported separately. A.18 directly compares heating-gradient refinement for all four FV variants with the continuous reference for the same problem. Finite-mesh differences and reference changes are reported separately. Scope remains the open-inner-boundary heating problem, not general flow around a body.
随页保存的点积结果:默认基准上,取状态方向 、设计方向 与种子 、,检查 。左侧 为 ,右侧 为 ,绝对差 ,相对差 。两侧共 247 项(左侧 96 个 加 ,右侧 96 个 与 54 个 ),分别由同一离散函数的前向图和反向图算出,最大单项为 ;只要实现正确,差别只来自舍入,相对差应是机器精度 的若干倍。若相对差明显更大(例如 ),通常说明某条路径漏乘、多乘或转置错了。
Saved dot-product result: on the default baseline, with state direction , design direction and seeds , , check . The left side is and the right side is , an absolute difference of and a relative difference of . The two sides hold 247 terms in all (left: 96 products plus ; right: 96 products and 54 products ), computed by the forward and reverse graphs of the same discrete function, the largest being ; for a correct implementation the difference is pure roundoff, a small multiple of machine precision . A clearly larger relative difference (say ) usually means a missing, extra or untransposed path.
残差缩放改变乘子,但不改变收敛总梯度
Residual scaling changes multipliers but not converged gradients
令 是可逆的行尺度矩阵。固定坐标时 给出 ;因此取 恰好保证伴随方程相同:
Let be an invertible row-scale matrix. At fixed geometry, gives . Choosing preserves the adjoint equation:
若尺度随坐标或参数变化,还会出现 。仅在原始残差已经为零时,这一项才消失。因此比较不同程序的伴随数值前,要先核对残差定义、体积缩放与强边界行。
If varies with design, a term appears and vanishes only at a converged state. Compare residual definitions, volume scaling and strong rows before comparing multipliers between codes.
图 16.1 把点积检验拆开看。左边逐面:每个面用附录 B.1 的 与 ,比较 与 ();右边整体:12 组互不相同的 与 ,比较 与 。
Figure 16.1 takes the dot-product test apart. Left, face by face: with the and of Appendix B.1, compare with () on every face; right, as a whole: for 12 different triples and , compare with .
逐面检验固定 ,只沿状态方向。它说明每个面的状态转置是对的;面循环对残差的贡献只是各面 之和,所以面循环里状态方向的部分也就成立。它不涉及整体检验里的其余部分:体积源项( 的列与坐标)、目标 ,以及面循环里所有 方向(参数与坐标)。以第 1 组随机种子为例,整体的 是面循环(所有方向) 、体积源 、目标 之和。所以各面都通过而整体失败,错在这些其余部分;某个面失败,则它指出出错的面。
The face-by-face test fixes and runs along the state directions only. It shows that the state transpose of every face is right, and since the face loop contributes to the residual only the sum of over the faces, the state directions of the face loop are right. It does not touch the other parts of the whole test: the volume sources (the column of and the coordinates), the objective , and all directions (parameters and coordinates) of the face loop. For the first random trial, the whole is the sum of the face loop (all directions) , the volume sources , and the objective . So when every face passes and the whole test fails, the fault is in these other parts; when a face fails, it names the face.
核对:物理绕流基准上 48 个面的逐面相对差最大为 ,12 组整体检验最大为 ,都是机器精度 的小倍数;第 1 组种子的分解(3 项)之和与整体的相对差为 (本页代码在构建时计算)
Check: on the physical-flow baseline the largest relative difference over the 48 faces is and over the 12 whole tests , small multiples of the machine precision ; the 3 parts of the first trial add up to the whole with a relative difference of (computed by this page's code at build time)
GS、GMRES 与 LU
GS, GMRES and LU
对同一个伴随系统比较 GS、GMRES 与 LU,以真实残差判断是否收敛。
Compare GS, GMRES and LU for the same adjoint system, using true residuals to judge convergence.
便宜修正始终针对精确系统
Inexpensive correction still targets the exact system
前向块 GS 从前往后扫描,转置作用从后往前,并转置每个块。固定迭代收敛需要 ,不是由格式名自动保证。预算耗尽或残差增长必须显示为未收敛。
Forward block GS sweeps forward; its transpose sweeps backward and transposes every block. Stationary convergence requires , not a method name. Budget exhaustion or growth must be shown as nonconvergence.
(记号见下文推导。)GMRES 使用右预处理,Arnoldi 正交化两次;每轮恢复解并计算真残差,必要时重启。比较时使用同一个基态、同一个右端和同一真残差尺度。LU 的准备包括显式组装和分解;小网格的速度不能直接推广到工业规模。
(The symbols are defined in the derivation below.) GMRES uses right preconditioning and two orthogonalization passes, reconstructing solutions to measure true residuals and restarting if needed. Compare the same base, right-hand side and true-residual scale. LU preparation includes assembly and factorization; small-grid timing does not prescribe industrial-scale methods.
随页保存的求解器比较
Saved solver comparison
| 方法Method | 迭代次数Iterations | 是否收敛Converged | 最终真残差Final true residual |
|---|---|---|---|
| 块 GS(ω=0.5)Block GS (ω=0.5) | 500 | 预算内未收敛not within budget | 1.361e-10 |
| GS 预处理 GMRESGS-preconditioned GMRES | 66 | 是yes | 4.571e-11 |
| FGMRES · ASM/ILU(1)FGMRES · ASM/ILU(1) | 29 | 是yes | 6.248e-11 |
| 稠密 LUDense LU | 1 | 是yes | 2.439e-16 |
GS 在 500 次后残差为 ,没有达到 ,页面如实报告未收敛;两种 Krylov 方法以精确乘积计算真残差,LU 一次消元即得到舍入级残差。计时依机器而定,这里不列出。
After 500 iterations GS stops at , above , and the page reports nonconvergence; both Krylov methods measure true residuals with exact products, and LU reaches a roundoff-level residual in one elimination. Timings depend on the machine and are omitted.
从三角方程得到 GS 扫描式
Derive the GS sweep from a triangular system
将预处理矩阵 分成块对角 、严格下三角 和严格上三角 。一次从零开始的前向 GS 扫描就是求解 ;逐行把已经求出的量移到右侧:
Split the preconditioner into its block diagonal , strict lower and strict upper . A forward GS sweep from zero solves . Move already known terms to the right:
对伴随使用这个线性作用的精确转置:先转置三角系统,再从后往前解。它不等于“仍然从前往后扫一遍,只把名称改成 adjoint”。
For the adjoint use the exact transpose of this linear action: transpose the triangular system and solve backward. It is not the forward sweep run again under the name 'adjoint'.
从固定点误差到 GMRES 的最小二乘
From fixed-point error to GMRES least squares
误差传播矩阵决定固定点是否收敛。GMRES 则在逐步扩大的子空间里选择使残差最小的修正。令当前残差 ,从 开始反复作用 并正交化,得到 Arnoldi 关系( 是 上 Hessenberg 矩阵;本页上横线专指反向导数):
The error-propagation matrix controls stationary convergence. GMRES instead chooses a residual-minimizing correction in an expanding subspace. Set , start with , and repeatedly apply and orthogonalize (the upper Hessenberg matrix is written : on this page a bar always means a reverse derivative):
由于 的列正交归一,上式的二范数等于小向量的二范数,最终只需解一个小规模最小二乘问题。若 ,修正公式里不能漏掉它;本页每次 GMRES 修正从零初值开始,再加回外层解。
Because the columns of are orthonormal, its residual norm equals the norm of the small vector, leaving a small least-squares problem. Retain for nonzero starts; this implementation solves zero-start corrections and adds them to the outer iterate.
图 17.3 在默认制造解基准上播放伴随方程 的求解。先是从零出发的第一次转置 GS 扫描:块 从单元 23 倒着算到单元 0,每一块用到已经算好的、编号更大的块,正是上面推导的反向扫描;然后是阻尼 的定常迭代(第 1、2、3、5、10、20、50、100、200、500 次),最后是 GS 预处理的 GMRES 的各个迭代向量(共 66 次)。右图的真残差曲线标出当前一帧:GS 曲线不设停止判据,画满 500 次:第 500 次仍为 ,没有低于容差 (上面的表如实报告未收敛);GMRES 在 66 次达到 。
Figure 17.3 plays the solution of the adjoint equation on the default manufactured baseline. First the first transposed GS sweep from zero: the blocks run backwards from cell 23 to cell 0, each using the blocks with larger numbers already computed, which is the backward sweep derived above; then the stationary iteration with damping (iterations 1, 2, 3, 5, 10, 20, 50, 100, 200, 500); finally the iterates of GS-preconditioned GMRES (66 iterations). The true-residual curves on the right mark the current frame: the GS curve has no stopping test and is drawn for all 500 iterations: it stands at at iteration 500, above the tolerance (the table above reports nonconvergence); GMRES reaches in 66.
核对:逐块算出的第一次扫描与本页预处理器 precondition().apply(r, true) 的结果相比,最大相对差为 0(两者逐位相同);GMRES 的最后一个迭代向量与随页保存的伴随解相比为 (两者的容差都是 )(本页代码在构建时计算)
Check: the first sweep computed block by block agrees with this page's preconditioner precondition().apply(r, true) to a maximum relative difference of 0 (identical to the last digit); the last GMRES iterate agrees with the stored adjoint solution to (both solved to a tolerance of ) (computed by this page's code at build time)
工程规模:分层预处理与离线计算
Engineering scale: hierarchical preconditioning and offline computation
GS 用于展示一次三角修正。网格加密后,黏性耦合与重构邻域会使它的收敛变慢,甚至在给定预算内停滞。本页默认求解采用右预处理 FGMRES;GS 和 GS–GMRES 是同一系统上的教学对照。
GS illustrates a triangular correction. On refined meshes, viscous coupling and reconstruction stencils can make it slow or cause stagnation within the available budget. The default solver is right-preconditioned FGMRES; GS and GS–GMRES serve as comparisons on the same system.
预处理结构参照 OVERU 的 hierarchical 分支:外层精确算子,内层可选 Richardson 修正、重叠子域的限制型 ASM、局部 ILU(k)。这里是独立的串行教学实现;不调用 OVERU 或 PETSc,也不含 MPI、湍流和多重网格。
The hierarchy follows OVERU’s hierarchical branch: an exact outer operator, optional Richardson corrections, restricted overlapping ASM and local ILU(k). This is an independent serial teaching implementation, without OVERU/PETSc bindings, MPI, turbulence or multigrid.
一阶近似只降低对流重构;本页黏性重构、物性和实际边界求导仍保留。默认 。离线程序可用 增强对角,其中 为控制体体积重复到四个分量,强边界约束行取零。它仅加入预处理矩阵,不能加到最终检验的精确 中。
The approximation lowers convection reconstruction only; viscous reconstruction, transport and actual boundary derivatives remain. The default is . Offline, can strengthen the diagonal, using repeated control-volume weights and zero weight for strong constraints. This term belongs only to the preconditioner, never to the exact used for the final residual.
读取子域及重叠区, 只把该子域拥有的自由度写回。这样每个全局未知量只接受一次子域修正。四个守恒量始终一起分区。ILU(0) 不增加原矩阵之外的非零位置;ILU(1) 保留一级消元填充。更多填充和重叠会增加内存与准备时间,不能只比较迭代次数。
reads a subdomain including overlap; scatters only owned degrees of freedom. Each global unknown receives one subdomain correction. All four conserved components stay together. ILU(0) preserves the original sparsity; ILU(1) permits level-one elimination fill. More overlap and fill cost memory and setup time; iteration counts alone are insufficient.
其中 是全局 Richardson 阻尼,默认 1(不是上面块 GS 的 0.5)。内层全局 Richardson 始终用近似 计算内层残差;外层 FGMRES 用精确 。局部也可在 上重复修正。默认一个全局、一个局部修正、一级重叠、ILU(1),不把“增加内迭代”当作必然更好的选择。FGMRES 保存每次实际得到的 ,适合以后引入可变内层求解。
Here is the global Richardson damping, default 1 (not the block-GS 0.5 above). Global inner Richardson uses approximate for its residual, while outer FGMRES uses exact . Local corrections may likewise repeat on . Defaults are one global and one local application, one overlap layer and ILU(1). More inner iterations are not automatically better. FGMRES stores each actual , allowing future variable inner solves.
伴随路径先转置近似矩阵,再建立子域和 ILU。限制型 ASM 对 的作用一般不等于对 所建预处理作用的精确转置;这不影响外层精确伴随方程。不能因此把两种预处理误称为严格点积对偶。小主元保护会计入 shifted_pivots;求解仍必须通过真实残差检查。
The adjoint path transposes the approximate matrix before building subdomains and ILU. Restricted ASM built for is generally not the transpose of the preconditioner built for . This does not change the exact outer adjoint equation, but the two PC applications must not be called exact dot-product duals. Small-pivot protection is counted in shifted_pivots; the true residual remains the acceptance criterion.
P0 = first_order_jacobian(U) + alpha_P * volume_weights
P = transpose(P0) if adjoint else P0
PC = Richardson(restricted_ASM(local_ILU(P)))
for each Arnoldi step:
z[j] = PC(basis[j])
w = exact_JVP(z[j]) or exact_VJP(z[j])
orthogonalize_twice(w)
x = x0 + Z @ y
accept only if norm(b - exact_operator(x)) meets tolerance浏览器的边界:每次计算最多 2 秒
Browser boundary: at most 2 seconds per computation
网页用于小网格演示。耗时求解在 Worker 内运行,每次操作的全部子步骤共享 2 秒总预算;超时终止,不将未完成计算作为收敛结果。默认小网格先加载已经核验的基准,逐面运算仍可交互。加密曲线可以浏览随页的已保存结果,不要求在浏览器重算。不同机器速度不同,因此实际超时也会触发停止。
The browser is for small-grid demonstrations. Expensive solves run in a Worker with a shared two-second wall-clock budget for the entire operation. Timeout terminates computation and is not accepted as convergence. Verified small-grid baselines load first; face-by-face operations remain interactive. Saved refinement curves can be inspected without recomputation. A slower machine can also trigger the cutoff.
下载纯 Python 大网格程序Download the pure Python large-grid program 运行说明与验证范围Instructions and validation scope
当前下载包覆盖本页固定多边形的光滑制造解及真实壁面绕流:格心/格点、一阶/二阶、两种物性、等温/绝热边界、温度/力/热流目标、原始/前向/伴随、六个物理参数梯度及固定拓扑坐标导数。Python 自行生成加密网格,用着色复数步长装配稀疏 Jacobian;不是把网页搬到 Node.js,也不宣称是 matrix-free。支持指定网格变形方向、稀疏坐标 Jacobian 和重算网格差分;该包尚不包含独立连续有限元求解器;连续参考曲线仍是已保存的独立计算结果。
The download covers fixed-polygon smooth MMS and physical wall flow: cell/node, both orders and transport laws, isothermal/adiabatic walls, temperature/force/heat objectives, primal/tangent/adjoint, six physical parameter gradients and frozen-topology coordinate derivatives. Python generates refined meshes and assembles sparse Jacobians by coloured complex-step differentiation. It does not invoke Node.js and is not matrix-free. Selected mesh deformations, sparse coordinate Jacobians and deformed-mesh FD are supported. The independent continuous FEM solver is not included; continuous-reference curves retain separately computed saved data.
python -m venv .venv # Activate the environment, then: python -m pip install -r requirements.txt python verify.py python run.py --scheme node --order 2 --level 3 \ --transport sutherland --parts 4 --overlap 1 --fill 1 \ --restart 100 --output results/node_o2_l3
总结
Summary
按“存储 → 重构 → 通量 → 装配 → 求导 → 求解 → 验证”复述一次完整计算。
Explain the full sequence: storage → reconstruction → flux → assembly → differentiation → solve → verify.
从二维标量到二维 N–S,面装配仍是“左加右减”,但单个通量背后已包含四个守恒量、多点重构、应力张量、导热、无滑移或自然牵引边界。精确离散伴随必须反转整条计算链,而不是只转置无黏通量。
From the scalar equation to 2D N–S, face assembly remains add-left/subtract-right, but each flux includes four states, multipoint reconstruction, stress, heat and no-slip or natural-traction boundaries. An exact discrete adjoint reverses the entire chain, not only inviscid fluxes.
对流部分与二维 Euler 页同为 Rusanov(局部 Lax–Friedrichs)型通量,黏性应力与导热沿用一维 N–S 页的物理模型。耗散速度在二维 Euler 页取常数 2,本页取常数 3,一维 N–S 页取两侧平均 ½(u⁻+u⁺+c⁻+c⁺),原因见第 3 节。
The convective part is the same Rusanov (local Lax–Friedrichs) type flux as on the 2D Euler page, and viscous stress and heat conduction follow the physical model of the 1D N–S page. The dissipation speed is the constant 2 on the 2D Euler page, the constant 3 here and the two-sided average ½(u⁻+u⁺+c⁻+c⁺) on the 1D N–S page; Section 3 explains why.
当前范围是小型固定拓扑、无 limiter、无激波的层流教学系统。它展示通向二维非结构 RANS 所需的几何、黏性和边界求导;湍流模型及其边界、网格变形映射、并行分区和大规模稀疏求解仍是后续内容。判断正确性时,应同时看数学定义、实际执行、局部验证与全链路验证。
The current scope is a small fixed-topology, unlimited, shock-free laminar teaching system. It demonstrates geometry, viscous and boundary derivatives needed on the path to 2D unstructured RANS. Turbulence and its boundaries, mesh-motion maps, parallel partitioning and large sparse solves remain future work. Judge definitions, actual execution, local checks and full-chain checks together.
建议按一条完整计算链复习
Review one complete computation chain
先在网格上选一个面,说清左右存储量、求积点与法向;再用该面检查对流、应力与热流的符号。随后播放残差、前向与反向,核对邻域贡献为什么需要累加。最后选择一个物理参数或坐标,比较 tangent、adjoint、不同步长差分及复数步长。做到这些后,再看加密图判断空间精度;两种判断回答的是不同问题。
Select a face and identify stored states, quadrature points and normals. Check signs of convection, stress and heat. Replay primal, tangent and reverse assembly and explain why stencil contributions accumulate. Choose one physical parameter or coordinate and compare tangent, adjoint, finite-difference steps and complex step. Then use refinement to assess spatial accuracy: it answers a different question.
| 原始Primal | 前向Tangent | 伴随Adjoint | |
|---|---|---|---|
| 输入Input | U | U, U_d | U, R_b |
| 输出Output | R(U) | R_d = A U_d | U_b = A⊤R_b |
| 读入范围Read from | L/R 与邻域L / R + stencil | L/R 与邻域L / R + stencil | 屏蔽后的残差行masked residual rows |
| 写回范围Write to | L += H, R −= H | L += H_d, R −= H_d | 所有依赖状态的 U_bU_b of all dependent states |
| 边界顺序Boundary order | 最后替换约束行replace rows last | 最后对替换求导differentiate replacement last | 先反传替换reverse replacement first |
附录 A:二维连续伴随推导与边值求解
Appendix A: two-dimensional continuous-adjoint derivation and BVP solve
A.1 从本页的二维物理方程开始
A.1 Start from the physical two-dimensional equations
本附录先完成二维连续伴随的推导,再检查二维恒等式、独立求解二维边值问题及验证梯度,最后保留一维限制问题的独立求解。正文的 是有限维残差的乘子;本附录的 是连续守恒方程的乘子。二者的定义不同。以下取固定、分片光滑的二维域 ,法向 一律指向流体域外;内壁上的法向因此指向固体。先假设基态和扰动足够光滑、,且边界分区不变。
This appendix derives the two-dimensional continuous adjoint, checks a two-dimensional identity, independently solves the two-dimensional BVP and verifies gradients, and finally retains the independent one-dimensional restricted solve. The main text uses the finite-dimensional residual multiplier ; here multiplies continuous conservation equations. Start with a fixed piecewise-smooth domain and an outward-from-fluid normal n, which points into the solid on the inner wall. Assume sufficiently smooth base fields and variations, positive , and a fixed boundary partition.
这里 , 是比总焓,不是正文中的离散面通量。下标 重复时求和;。本页无量纲气体常数为 1,故 。连续守恒残差为:
Here , and H is specific total enthalpy, not the discrete face flux. Repeated indices are summed and . The nondimensional gas constant is one, so . The continuous conservation residual is:
是 Kronecker 符号, 中的 才表示一阶变化。系数 保留三维 Newton 流体在平面运动下的本构关系。能量通量的符号必须是“对流 − 黏性功 + 热流”;把 再代入时,导热项为负。制造源项在本页局部求导时固定基准,外加热源仅依赖位置与参数,因此实际 ;为便于检查,下面先保留一般的 。
The symbol is the Kronecker delta, whereas denotes a variation. The factor retains the three-dimensional Newtonian constitutive law for plane motion. Energy flux is convection minus viscous work plus heat; substituting gives a negative conduction term. The implemented local derivatives keep manufactured forcing at its baseline, and added heating depends only on position and parameters, so here. Keep a general in the derivation to expose its sign.
A.2 把状态、应力与热流的变化全部展开
A.2 Expand state, stress and heat-flux variations
先固定参数与几何。由乘积法则、商法则得到下列局部关系;连续 tangent 方程不是把黏性系数冻结后再求导。
First fix parameters and geometry. Product and quotient rules give the local relations below. The continuous tangent does not freeze transport coefficients before differentiating.
最后一行包含两条黏性功路径:速度的变化乘已有应力,以及已有速度乘应力的变化。漏掉任意一条都会改变连续伴随。将这些变化放入 ,便得到连续线性化方程;参数不固定时,另加 与 。
The last row has two viscous-work paths: velocity variation times existing stress, and existing velocity times stress variation. Omitting either changes the adjoint. Substitution into gives the continuous linearized equation. Parameter directions additionally contribute and .
A.3 目标函数与乘子的符号约定
A.3 Objectives and the multiplier sign convention
温度跟踪取 ,并取 ;x/y 力目标分别取 或 ;流体向外热流取 。这里严格沿用程序对 的定义,若另行采用反向法向或相反的力定义,目标及其伴随边界条件的符号也要一起改变。
Temperature tracking uses , with g zero. The x/y force objectives use f zero and or with . Outward heat uses f zero, d zero and . The definition follows the code exactly. Reversing the normal or force convention also reverses the associated objective and adjoint boundary signs.
在顶部两种算例中,只有绕流模式的内圈是固壁(第 15 节的无滑移制造解 wallmms 也用固壁闭合)。制造解模式仍可在内圈积分同样的力/热流表达式,但那里施加的是入流或牵引数据;不能套用下面的固壁伴随条件。对这一情况,应把 保留在通用边界式中,再按实际边界代入。
Among the header cases only the physical-flow mode has an inner solid wall (the no-slip manufactured study of Section 15, core.js mode wallmms, also closes it as a wall). MMS can integrate the same force/heat expression on its inner boundary, but that boundary has inflow or traction data. Do not apply solid-wall adjoint conditions there: retain in the general boundary form and substitute the actual conditions.
采用加号后,体内伴随满足“目标导数 + 残差算子的转置作用 = 0”,与正文 的符号一致。但连续算子里的空间分部积分不是离散矩阵转置。对温度跟踪,原始变量目标导数只有 非零;换成守恒变量则:
With the plus sign, the interior equation is objective derivative plus adjoint residual action equal to zero, matching the sign of . Spatial integration by parts is nevertheless distinct from a discrete transpose. For tracking, only is nonzero in primitive variables. In conservative variables:
A.4 守恒变量的线性算子与两次分部积分
A.4 Conservative linearization and two integrations by parts
每个 都是 矩阵。 已包含黏性通量对状态的依赖,包括物性和守恒量到原始量梯度的转换,不能只拿 Euler 通量 Jacobian 代替。 是总通量“对流减黏性”的梯度导数,已包含相应负号。
Each is a matrix. includes viscous state dependence, transport, and conversion of conservative gradients to primitive gradients; an Euler flux Jacobian alone is insufficient. differentiates the total convection-minus-viscosity flux and already carries its signs.
内部扰动可以任意取,因此方括号必须为零。这给出二维连续伴随的紧凑形式。系数依赖基态和空间,故必须保留 的乘积导数;把它直接写成常系数二阶导数会遗漏项。下面进一步展开,使读者无需先构造四个黏性矩阵。
Interior variations are arbitrary, so the bracket must vanish. This is the compact two-dimensional adjoint. Coefficients depend on the base field and position: retain the product derivative . Replacing it by a constant-coefficient second derivative loses terms. We next expand the equation without requiring the reader to construct all viscous matrices first.
A.5 用原始变量收集系数:伴随应力的来源
A.5 Collect primitive-variable coefficients: the adjoint stress
第一次分部积分后,体积分中与通量有关的项是 。把 A.2 逐项代入。与应力变化相乘的系数,以及与温度梯度变化相乘的系数分别为:
After the first integration by parts, the flux term is . Substitute A.2 term by term. The weights of stress variation and temperature-gradient variation are:
例如 交换求和下标后成为 ,所以出现 ;迹项给出 。 是这次分部积分产生的系数张量,不是原流场的应力。它还包含 ,这正是黏性功的反向耦合。二维分量为:
For example, swapping summed indices changes into , producing . The trace term supplies . Z is an integration-by-parts coefficient tensor, not the primal stress. Its term is the reverse coupling from viscous work. In two dimensions:
A.6 完整展开的四个二维伴随方程
A.6 The four expanded two-dimensional adjoint equations
记 ,其中 是一个原始变量。收集 的系数,分别置零。密度方程是:
Write for a primitive variable z. Collect and set to zero the coefficients of . The density equation is:
对每个速度分量 ,得到两个方程:
For each velocity component , obtain two equations:
温度方程为:
The temperature equation is:
式中 ,两个剪切位置都要计入。若 只随 变化且参数空间均匀,导热的后两项恰好合成 ;这不是允许在其他项中冻结变物性。黏度导数项 仍保留, 也仍须对 求空间导数。
The contraction includes both shear positions. If conductivity depends only on T and parameters are spatially uniform, its last two terms combine to . This cancellation does not permit freezing transport elsewhere: remains, and still differentiates in space.
这四个方程仍以守恒残差的乘子 为未知,只是用原始变量扰动收集系数。不是把乘子偷偷换成另一套变量。令 ,则展开系数 ; 下 可逆,两种形式等价:
The unknowns are still the conservative-residual multipliers ; only the variation coordinates changed. With , the expanded coefficients satisfy . The invertible change of variables makes both forms equivalent:
密度对应方程没有独立的二阶扩散项,不能给四个伴随分量一律施加同一种 Dirichlet 条件。黏性并没有把可压缩 N–S 变成四个独立的 Poisson 方程。
The density equation has no independent second-order diffusion term. One cannot impose the same Dirichlet condition on all four adjoint components. Viscosity does not turn compressible N–S into four independent Poisson equations.
A.7 一个通用边界式,决定全部伴随边界条件
A.7 One boundary form determines the adjoint conditions
固定几何,先把法向总通量改写为边界数据 的形式,再求变化。定义 为不含目标变化的边界密度,则:
At fixed geometry, rewrite the normal total flux in terms of boundary data before varying it. Let denote the boundary density excluding the objective variation:
该式是 A.4 边界项的原始变量展开。边界上的 不能全部视为互相独立,更不能全部设零;它们必须满足原边界条件的线性化。正确做法是先代入已知为零的变化,再对剩余自由变化令系数消失。边界有目标时,还要先加上 。
This is A.4's boundary term expanded in primitive variables. Boundary variations are neither all independent nor all zero: they satisfy the linearized primal boundary conditions. Substitute prescribed variations first, then cancel coefficients of the remaining free variations. Include before doing this on an objective boundary.
A.8 入口与总牵引出口
A.8 Inflow and total-traction outflow
入口给定 ,固定参数时 。法向速度梯度、温度梯度仍可改变,所以 与 可以非零。若入口没有边界目标,则边界式化为 ,从而:
Inflow prescribes , so at fixed parameters. Normal velocity and temperature gradients may still change, making and nonzero. Without an inlet objective, the boundary form reduces to , hence:
入口不另加 的边界值。
No additional inlet value is imposed for .
总牵引出口固定 ,所以 ,而 的边界迹是自由的。无出口目标且 时:
At a total-traction outlet, fixed gives , leaving the boundary traces of free. With no outlet objective and :
第一个条件等价于 ;第二个是两个耦合的导数条件;第三个是能量伴随的 Robin 条件。它们来自本页“总牵引 + 热流”出口,不能换成“给定压力”出口的伴随条件。若 ,未约简的条件只有 ,不能除以 ;若发生回流,还必须重新分析原边界问题的信息数和稳定性。当前推导不为这两类退化情形作适定性保证。
The first condition gives ; the second is a pair of coupled derivative conditions, and the third is a Robin condition for the energy multiplier. These belong to the implemented total-traction-plus-heat outlet, not a prescribed-pressure outlet. If , retain the unreduced rather than dividing. Backflow requires a fresh analysis of the primal boundary problem and stability. No well-posedness claim is made for either degeneracy.
A.9 静止壁面:目标函数改变伴随边界值
A.9 Stationary walls: the objective changes adjoint boundary values
静止无滑移壁面有 ,所以即使 自由,它在边界式中的系数仍为零。力/热流目标变化为 (此处 固定)。代入后只剩:
At a stationary no-slip wall, . Density may vary but its boundary coefficient vanishes. With fixed and , . The remaining form is:
等温壁固定 ,因此 ,热流可以变化。绝热壁固定 ,因此 ,温度可以变化。分别消去自由项,得到:
An isothermal wall fixes , giving while heat may vary. An adiabatic wall fixes , giving while temperature may vary. Cancel the respective free terms:
| 目标Objective | 等温壁Isothermal wall | 绝热壁Adiabatic wall |
|---|---|---|
| 温度跟踪Temperature tracking | ||
| x 力x force | ||
| y 力y force | ||
| 向外热流Outward heat | 固定绝热时目标恒为零Zero objective for fixed adiabatic data |
这些壁面都不另外指定 。绝热热流目标恒为零,在唯一可解的齐次伴随问题中可以取 ;但若把规定热流本身作为参数,允许 ,就不再是同一个“固定绝热”方向。制造解内圈若有力目标,在入口应改成 ;热流目标应改成 。在固定牵引/热流出口,这两个目标的状态变化为零,但它们仍可有显式参数导数。
No additional alpha condition is imposed at either wall. For a fixed adiabatic heat objective, J is identically zero; the unique homogeneous adjoint, when it exists, is zero. Varying prescribed heat itself with is a different design direction. If the MMS inner inlet carries a force objective, use ; for heat use . At a fixed-traction/fixed-heat outlet these objectives have zero state variation, but may still have explicit parameter derivatives.
A.10 固定域上的参数梯度
A.10 Parameter gradients on a fixed domain
连续伴随消掉未知流场响应后,仍要保留通量、源项、目标和边界数据对参数的显式依赖。先令边界数据与参数无关,并以 表示固定 的偏导:
After eliminating the unknown state response, retain explicit parameter dependence of flux, source, objective and boundary data. First suppose boundary data are parameter independent, and let denote a partial derivative at fixed :
这是从尚未代入参数边界变化的 Lagrangian 得到的通式。若原边界算子也依赖物性或规定数据, 的边界变化不再齐次,还必须加回 A.7 对该非齐次变化产生的边界项。下面用总的 消去自由变化,能避免把物性边界项算两次。对空间均匀的物性参数,体积分中的显式输运部分是:
This general formula precedes parameter-dependent boundary substitution. If a primal boundary operator depends on transport or prescribed data, boundary state variations become inhomogeneous and their A.7 contributions must be included. Using total to eliminate free variations avoids counting transport boundary terms twice. For spatially uniform transport parameters the explicit volume contribution is:
实际使用时,将这项体积分与“已代入全部边界条件”的总边界式组合,而不是再叠加一遍 。例如没有边界目标的入口,,给定数据变化产生下列贡献;给定牵引/热流的出口也有直接贡献:
In practice combine this volume term with the total boundary form after all boundary conditions are substituted, without adding a second time. For example, an inlet without a boundary objective has , and changes in prescribed data contribute as below; outlet traction/heat data also contribute directly:
静止等温壁的壁温参数留下 ;固定壁面速度时没有速度数据项。若边界目标显式依赖数据,还要加入相应项,例如热流目标的 。外加源 的体内贡献为:
A stationary isothermal wall-temperature parameter leaves ; fixed wall velocity contributes no data variation. Explicit objective dependence on boundary data also remains, such as for heat. Added heating contributes:
A.11 几何变化不能混进固定域公式
A.11 Geometry changes require a domain transformation
正文还提供坐标导数。要构造连续的对应量,必须先规定连续变形,而不能把一个网格节点编号当成连续设计变量。设 ,,采用拉回到基准域的材料变化 。链式法则、面积与法向变化给出:
The main text also provides coordinate derivatives. A continuum counterpart requires a specified continuous deformation, not just a mesh-node index. Let , , and use the pulled-back material variation . Chain rule, area and normal variations give:
一个可直接实现、但尚未化成仅含壁面法向位移的表达式,是先对守恒残差使用 Piola 拉回。定义 为固定材料场时由位置、参数、梯度变形引起的通量变化, 类似;这里状态梯度的几何部分是 。于是拉回通量的一阶变化为:
A directly implementable expression, not yet reduced to a wall-normal-only shape gradient, follows from a Piola pullback. Define as the flux variation from position, parameters and gradient deformation at fixed material field, with analogous . Its geometric state-gradient part is . The pulled-back flux variation is:
在已收敛基态,拉回乘子的任意变化乘 。利用已经推导的伴随消去材料状态的自由变化后,几何与显式参数贡献为:
At a converged base state, any multiplier variation multiplies . Eliminating free material-state variations with the derived adjoint leaves geometry and explicit-parameter contributions:
是非齐次材料边界变化留下的项,不能省略; 包含法向变化。规定的空间温度在移动位置采样时有 ;材料固定壁温则 。若改用 Eulerian 变化 ,静止无滑移壁上为 ,而不是 。不同约定必须整套转换。
The term contains inhomogeneous material boundary variations and cannot be omitted; includes normal changes. Spatially prescribed temperature sampled at a moving position has , whereas material-fixed wall temperature has . With an Eulerian variation , a stationary no-slip wall has , not zero. Transform the entire convention consistently.
为明确 的计算,把牵引/热流总材料变化拆成状态部分与固定材料场的几何部分:、。纯几何方向中 ,而:
To make explicit, split total material traction/heat variations into state and fixed-field geometric parts: and . For a pure geometry direction, and:
于是牵引出口在 A.7 中使用 ;无出口目标时,留下 如下。不能既把总的 代入,又遗漏上述几何减项。
Thus use and in A.7 at a traction outlet. Without an outlet objective this leaves the contribution below. Substituting total prescribed-data changes without the geometric subtractions would be incorrect.
例如本页物理出口 ,所以 ,并非零。固定材料温度的等温静止壁给 ,状态边界项与目标的状态变化互相抵消;绝热静止壁则有 ,留下 。目标本身的 已在上式单独计入。这说明几何法向、热流约束和目标变化必须一起处理。
For the implemented physical outlet , one has , not zero. A stationary wall with material-fixed temperature has , and its state boundary form cancels the objective state variation. An adiabatic stationary wall instead has and leaves . The explicit objective term is already included separately above. Normal geometry, heat constraints and objective variations must therefore be treated together.
上式是完整的拉回求导记账式,不是本页已经计算的二维连续形状梯度。多边形角点没有唯一法向;边界分区交界移动还会产生接口贡献。正文坐标导数则始终是实际离散函数的导数,包含重构、面求积和格点边界行,不能用忽略这些结构的连续式替代。
This is a complete pullback differentiation identity, not an already computed 2D continuous shape gradient. Polygon corners have no unique normal, and moving boundary-partition interfaces require interface contributions. The implemented coordinate derivatives belong to the actual discrete function, including reconstruction, quadrature and strong nodal rows; the continuum expression does not replace them.
A.12 二维 Green 恒等式:逐项核对推导
A.12 A two-dimensional Green identity checks the derivation
下面的实验在独立方形域 上使用预先给定的光滑 ,不要求它们满足原始或伴随方程。左边直接对物理通量求方向导数并取散度;右边独立使用 A.6 的展开系数与 A.7 的边界式。设 ,应满足:
This independent experiment uses prescribed smooth on the square ; none needs to solve the primal or adjoint equations. The left side differentiates physical flux and takes its divergence. The right side independently uses the expanded A.6 coefficients and the A.7 boundary form. With , it should satisfy:
切换常物性/Sutherland、扰动分量及求积阶数;查看四个边的带符号贡献,而不是只看总误差。勾选漏掉导热边界项后,改变的是右边的演示公式,原始左边保持不变。程序用嵌套空间方向运算求导,没有有限差分步长。此实验验证二维微分与分部积分公式,不是完整二维连续伴随边值求解。
Switch constant/Sutherland transport, perturbation component and quadrature order. Inspect the signed contribution of each edge, not only the total error. Omitting the thermal boundary term deliberately changes only the displayed right side. Nested spatial directional arithmetic differentiates without a finite-difference step. This checks the 2D differential and integration-by-parts formulas, not a full continuous-adjoint boundary-value solve.
测试物性固定 ,不读取顶部主算例参数。以下给出可复现的全部测试场;选择单分量只把 的其他分量置零,不改变基态或乘子。
The test fixes , independently of the main case controls. All prescribed fields are listed below. Selecting one component zeros the other components of without changing the base state or multipliers.
查看二维恒等式的测试函数Inspect the 2D identity test fields
rho = 1 + .06*x + .03*y + .01*x*x + .01*x*y u = .22 + .04*x - .03*y + .02*x*y + .01*y*y v = .05 + .02*x + .04*y + .01*x*x - .01*x*y T = 1 + .08*x + .05*y + .02*x*x + .03*x*y + .01*y*y alpha = .2 + .17*x - .08*y + .04*x*x + .06*x*y beta1 = -.1 + .07*x + .11*y + .03*x*x - .02*x*y + .02*y*y beta2 = .13 - .05*x + .09*y + .04*x*y - .03*y*y chi = .3 + .12*x - .07*y + .05*x*x + .02*x*y + .04*y*y drho = .07 + .03*x - .02*y + .01*x*y du = -.04 + .02*x + .06*y + .01*x*x - .02*y*y dv = .03 - .05*x + .02*y + .02*x*x + .01*x*y dT = .09 + .04*x - .03*y + .02*x*x - .01*x*y + .01*y*y
A.13 边界消项交互与适定性边界
A.13 Boundary cancellation and the limits of well-posedness claims
下方使用一个法向倾斜的边界点,代入符合原始边界条件的测试变化,再检查 。选择力或热流目标时,可比较正确的非齐次伴随条件与“全部伴随值设零”的错误选择。出口演示使用 ;这里检验边界代数,不求解整个流场。
The panel below uses one oblique boundary point and test variations admissible for the primal condition, checking . Force and heat objectives allow comparison of the correct inhomogeneous adjoint conditions against incorrectly setting all adjoint values to zero. The outlet example uses . This tests boundary algebra, not an entire flow solve.
消去形式边界项是必要步骤,但不是存在性、唯一性或稳定性的证明。还需要原始解所在函数空间、线性化算子的可逆性、足够正则的边界与兼容数据。质量方程的退化结构、出口回流、驻点、角点以及热流/应力目标的边界正则性都可能影响适定性。这里不把离散矩阵可逆或点积测试通过当成连续问题适定的证明。
Canceling the formal boundary form is necessary but does not prove existence, uniqueness or stability. Function spaces, invertibility of the linearized operator, boundary regularity and compatible data remain required. Degenerate mass diffusion, outlet backflow, stagnation, corners and boundary regularity for heat/stress objectives may affect well-posedness. An invertible discrete matrix or a successful dot test does not prove the continuum problem well posed.
Given a smooth primal base W and an explicitly defined objective: 1. Compute mu, kappa, stress and all physical spatial gradients. 2. Form Q, Z and the four continuous adjoint equations in A.6. 3. Derive adjoint boundary data from A.7 for the actual primal boundary. 4. Discretize this continuous BVP independently; solve and refine it. 5. Evaluate parameter / geometry formulas with all boundary-data terms. 6. Compare independently reconverged continuum differences. The independent solver in A.14-A.15 implements these steps for the explicitly stated smooth verification family; shape solves are separate.
A.14 真正求解二维连续伴随边值问题
A.14 Solve the two-dimensional continuous-adjoint BVP
这里求解的是 A.6 的四个连续偏微分方程及 A.8–A.9 的边界条件,而不是正文有限体积 Jacobian 的转置。区域完全沿用本页的 O 型多边形:8 个四边形与 16 个三角形,内孔保留。连续乘子 同时依赖 ,包括混合导数、剪切应力、黏性功、热传导及 Sutherland 物性的全部导数。格心页与格点页共享这个独立连续参考;连续场的未知数不是正文的单元平均值或节点值。
This solves the four continuous PDEs in A.6 and the boundary conditions in A.8–A.9, not the transpose of the main finite-volume Jacobian. It uses exactly the same O-shaped polygon: eight quadrilaterals and sixteen triangles, retaining the inner hole. All four multipliers depend on x and y, including mixed derivatives, shear, viscous work, heat conduction and all Sutherland derivatives. The cell and node pages share this independent reference; its unknowns are not the main lesson’s averages or nodal states.
为让小型教学程序独立且易于检查,用总次数不超过 的二维 Chebyshev 多项式逼近每个乘子。它在整个区域连续可微,因此不需要再引入跨单元的伴随数值通量。网格单元用来定义区域与求积;增加 增加未知系数,绘图的细分只改善着色。这里的 是独立连续参考的逼近次数,不是正文的一阶/二阶对流开关。
For a small, inspectable independent solver, approximate each multiplier with two-dimensional Chebyshev polynomials of total degree at most . The approximation is differentiable throughout the domain, with no interelement adjoint numerical flux. Mesh cells define the region and quadrature; increasing adds unknown coefficients, whereas plotting subdivision only improves colouring. The choices belong to this independent reference, not the main first/second-order convection switch.
程序存储每个局部量的六个数:值、两个一阶导数、三个二阶导数。乘积法则直接给出导数,既不对空间导数取有限差分,也不调用正文的自动微分残差。把基函数逐一代入展开方程,就得到矩形矩阵 ;逐一代入入口、牵引出口或壁面条件,得到 。例如等温壁施加三个伴随值,绝热壁施加两个动量乘子和一个热学法向导数,不能给 任意追加壁值。
Every local quantity carries six numbers: its value, two first derivatives and three second derivatives. Product rules propagate these directly, without spatial finite differences or the main residual’s AD tape. Evaluating each basis function in the expanded PDE forms the rectangular matrix ; evaluating the inlet, traction-outlet or wall conditions forms . An isothermal wall specifies three adjoint values; an adiabatic wall specifies two momentum multipliers and one thermal normal derivative. Do not add an arbitrary wall value for .
每个多边形先剖成三角形,三角形内用 Duffy 变换和 Gauss 求积,边上用一维 Gauss 求积;边法向始终指向流体外。每向装配点数为 ,验算改用 ,防止只在装配点上“看起来精确”。先按列范数缩放矩阵,再采用带列主元的 Householder QR 求超定最小二乘,不构造 。后者会平方条件数;这里的矩阵也不是正文附录 C 用 LU 求解的离散方阵。缺秩、非正物性或错误的入出流分区必须报错。QR 求出了最小残差系数,并不意味着 PDE 残差已经为零。
Split polygons into triangles and use a Duffy transform with Gaussian volume quadrature, plus Gaussian edge quadrature. Normals always point out of the fluid. Assembly uses points per direction; validation uses nq+2 to avoid accepting accuracy only at assembly points. Scale columns and solve the overdetermined system using column-pivoted Householder QR, without forming , which squares the condition number. This is also distinct from the discrete square LU system in Appendix C. Rank loss, nonpositive transport or an invalid inflow/outflow partition must fail explicitly. Finding least-squares coefficients does not imply zero PDE residual.
for every volume sample xq:
W, grad(W), Hessian(W) = independent_base(xq)
for every multiplier component and spatial jet entry:
evaluate the expanded operator in A.6
accumulate its action on each polynomial basis
for every boundary sample xb:
form the actual inlet / traction / thermal-wall condition
scale rows by sqrt(quadrature weight), then scale columns
theta = pivoted_householder_QR([A_Omega; sqrt(w_b) A_Gamma], [b; sqrt(w_b) d])
validate PDE and boundary residuals using a different quadrature
repeat with higher nu; do not confuse fit completion with accuracy制造验证首先选定独立二维基态 (开口内圈时与正文解析制造解相同),再选定以下四个非平凡的参考乘子。体内右端取 ,边界右端取实际边界算子作用于 的结果。求解器只使用这些右端,不把 作为初值、系数或线性解;求解后才计算相对场误差。非齐次边界是制造目标的一部分,不能误解成温度跟踪目标的齐次边界。
Manufactured verification first specifies an independent 2D base W₀ (the main analytic manufactured state for an open inner boundary), then the four nontrivial reference multipliers below. Set the volume forcing to L*λ* and the boundary forcing to the actual boundary operator applied to λ*. The solver receives those right-hand sides, never the reference coefficients or solution. Relative field error is measured afterwards. The inhomogeneous boundary data belong to the manufactured objective, not the homogeneous tracking objective.
壁面验证另构造满足真实无滑移条件的制造基态:保留解析密度,把两个速度乘以 ,把温度改成 ,其中
For wall verification, construct a manufactured base satisfying actual no-slip conditions: retain analytic density, multiply both velocities by s, and replace temperature by 1+s(T₀−1), with
整个内壁均落在 的区域,故 ,可分别检验等温和绝热闭合。这是带制造源项的二维黏性边值问题,不是无源绕流的替代解;径向截断仅为 ,不把它称为解析光滑基态。这个基态在壁面附近整片静止;第 15 节的离散无滑移制造解则让壁面剪切和热流都不为零。
The entire inner wall lies where s=0, hence u=v=0, T=1 and qn=0, allowing separate isothermal and adiabatic checks. This is a forced two-dimensional viscous BVP, not a replacement solution for unforced flow around the body. The radial cutoff is C², not analytic. This base is at rest in a whole neighbourhood of the wall; the discrete no-slip manufactured solution of Section 15 instead has nonzero wall shear and heat flux.
随页保存的连续边值结果
Saved continuous BVP results
下表是 A.14 “加密”按钮默认设置(Sutherland、开口内圈、制造目标)的实际结果,由 run_continuous_evidence.js 调用同一求解器得到。场误差是与已知制造乘子的相对误差;PDE 与边界残差在另一组更高阶求积点上计算。
The table records what the A.14 refinement button computes with its defaults (Sutherland, open inner boundary, manufactured objective), obtained by run_continuous_evidence.js with the same solver. Field errors are relative to the known manufactured multipliers; PDE and boundary residuals use a separate, higher-order set of quadrature points.
| 次数 νDegree ν | 未知数Unknowns | PDE L²PDE L² | 边界 L²Boundary L² | 相对场误差Relative field error |
|---|---|---|---|---|
| 3 | 40 | 2.629e-2 | 6.015e-3 | 2.319e-2 |
| 5 | 84 | 5.764e-4 | 1.007e-4 | 5.273e-4 |
| 7 | 144 | 6.069e-6 | 8.815e-7 | 4.780e-6 |
| 9 | 220 | 3.696e-8 | 4.536e-9 | 2.342e-8 |
误差随 近似指数下降:从 的 降到 的 ,这是光滑解下谱逼近应有的表现。壁面制造解在 时的相对场误差为等温 、绝热 。梯度按钮在 上用三次独立非线性原始求解作中心差分():伴随梯度 (体内项 ,边界项 ),差分 ,相对差 。
The error falls roughly exponentially with : from at to at , as spectral approximation of a smooth solution should. The wall manufactured solutions reach relative field errors of (isothermal) and (adiabatic) at . The gradient button, at , differences three independent nonlinear primal solves (): adjoint gradient (volume term , boundary term ), difference , relative discrepancy .
A.15 用独立非线性原始求解核对连续梯度
A.15 Check continuous gradients by independent nonlinear primal solves
验证需要一个明确的目标,不能只为伴随方程随意填写右端。记 A.7 中乘在 前的四个系数为 ,并令 。开口内圈的制造目标定义如下,所有带星号或下标 0 的系数在参数变化中固定:
A gradient check requires a defined objective, not merely an arbitrary adjoint right-hand side. Let denote the four coefficients of in A.7, and . For the open inner boundary, define the manufactured objective below, holding every starred or baseline coefficient fixed during parameter variation:
对这个目标求变分,其体内项与边界项恰好给出上面的制造伴随右端。基态不精确时仍固定这些目标系数,不能重新制造目标来强迫误差消失。壁面验证采用同样原则:等温壁加 ;绝热壁加 。这些目标是算子与边界的验证工具,不是新的工程优化目标。
Its volume and boundary variations give exactly the manufactured adjoint right-hand sides above. Keep those objective coefficients fixed even when the numerical base is inexact; do not remanufacture the objective to force agreement. Wall verification follows the same construction: add −β*·(t−t₀)−χ*(qn−qn₀) on isothermal walls, or −β*·(t−t₀)−(κ∂nχ)*(T−T₀) on adiabatic walls. These are operator/boundary verification functionals, not proposed engineering objectives.
选取一个同时含 的方向 ,建立 。然后把原始体内源设为 ,入口数据取 ,出口牵引与热流取其物理值。这个族精确满足连续原始问题,但程序在 仍从多项式系数出发求解非线性最小二乘:解析线性化连续通量、QR 求更新、正值检查和回溯。解析族只用于建立右端和独立核验;并不作为数值解返回。更新范数小只说明到达当前空间的驻点,表中另列留出点的原始残差。
Choose a direction V varying in both x and y and define Wa=W₀+aV. Set the primal forcing to div F(Wa), inlet data to Wa, and outlet traction/heat to their physical values. This family solves the continuous primal problem exactly, but the implementation still solves nonlinear least squares in polynomial coefficients at a=0,±h: analytic continuous-flux linearization, QR updates, positivity checks and backtracking. The analytic family defines data and provides a separate check; it is not returned as the numerical solution. A small update means stationarity within the current space; a separate holdout primal residual is reported.
表中分别显示体内项、边界数据项、二者之和、独立原始重算中心差分及解析族目标差分。这个参数同时改变源项与入口/出口,因此不能漏掉边界项,也不能与正文“固定制造源项后再加入热源”的参数混淆。交互中的梯度重算目前限定为开口内圈;等温/绝热壁的已验证范围是四个伴随场及边界残差的收敛。
The table separates the volume term, boundary-data term, their sum, independently reconverged primal central differences and analytic-family objective differences. This parameter changes both forcing and inlet/outlet data, so boundary contributions are essential. It differs from adding heating while holding the main manufactured forcing fixed. Interactive gradient reruns are currently limited to the open inner boundary; validated wall coverage is convergence of all four adjoint fields and boundary residuals.
还可切换为真实的体内温度跟踪目标 ,此时体内右端为 ,入口/出口与固壁使用相应齐次伴随条件,没有已知精确伴随场。全局多项式在 O 型角点和入出流交界处解析不足;页面如实显示较大的 PDE/边界残差及梯度差,不把制造目标的成功移用于这个目标。能达到参考精度的局部弱形式与自适应加密见 A.16;本面板的温度目标用于说明全局逼近的局限。
You can also select the actual volume-temperature tracking objective JT=½∫(T−1−0.02 cos x)². Its volume right-hand side is (0,0,0,−(T−Td)), with the corresponding homogeneous adjoint inlet/outlet and wall conditions, and no known exact adjoint field. The global polynomial space under-resolves the O-domain corners and inflow/outflow junctions. The panel reports the larger PDE/boundary residuals and gradient discrepancy without transferring the manufactured objective’s success to tracking. A.16 gives the local weak form and adaptive refinement that reach reference accuracy; here tracking illustrates the limits of global approximation.
全部连续求解器源码随页面内嵌,计算在本机 Web Worker 中执行,点击停止可终止。正文的离散求解器、重构、目标及其验证流程保持独立。
All continuous-solver source is embedded in the page and runs in a local Web Worker; Stop terminates it. The main discrete solver, reconstruction, objective and verification flow remain independent.
A.16 温度跟踪:弱形式与局部加密
A.16 Temperature tracking: weak form and local refinement
为什么全局多项式参考不够准确
Why a global polynomial reference is inaccurate
温度基态光滑,并不保证伴随在带孔多边形上同样光滑。伴随的驱动来自目标导数,且入口与出口施加不同的条件;角点附近可能出现较大的梯度及较低的正则性。一个覆盖全域的低次多项式必须同时拟合这些局部变化。制造伴随是我们预先选定的光滑函数,它验证算子实现,却没有考验这种逼近困难。因此,不能用 A.14 的制造场误差替代真实温度目标的收敛证据。
A smooth temperature base does not guarantee an equally smooth adjoint on a polygon with a hole. The objective derivative drives the adjoint, while inflow and outflow impose different conditions; corners can produce large gradients and reduced regularity. One low-degree global polynomial must fit all these local variations. A manufactured adjoint is a deliberately chosen smooth function: it checks the operator but does not test this approximation difficulty. Its small error in A.14 cannot replace convergence evidence for the actual tracking objective.
实际诊断也排除了“只需把边界权重调大”的解释。在 Sutherland 物性、七次全局逼近下,把边界权重从 1、10 增至 100,边界 残差从 0.01734、0.00893 降至 0.00331,但温度方程的体内残差从 0.00634、0.02168 增至 0.04717。QR 只是求出了当前空间的最小二乘折中。局部弱形式的 128 三角形初始网格中,最大的 10 个单元承载约 60% 的局部残差指标,提示应增加局部分辨率。这不是对完整 N–S 角点奇异指数的证明。
Measurements also rule out simply increasing the boundary weight. For Sutherland transport and global degree seven, weights 1, 10 and 100 reduce the boundary L² residual from 0.01734 to 0.00893 to 0.00331, while the interior temperature-equation residual grows from 0.00634 to 0.02168 to 0.04717. QR finds a compromise in the chosen space. On the initial 128-triangle mesh of the local weak form, ten cells carry about 60% of the local residual indicator, suggesting local resolution. This does not prove a corner-singularity exponent for the full N–S system.
从强方程到只含一阶导数的弱形式
From strong equations to a first-derivative weak form
仍使用 A.1–A.9 的同一连续方程和边界条件,固定开口内圈的制造基态 。令 是原始场的任意允许变化。入口速度和温度固定,故 ;密度数据通过质量流入通量弱施加,因此不在离散入口删去密度试验函数。入口伴随取 , 不额外指定。出口固定牵引 与热流 。
Keep exactly the continuous equations and boundary conditions in A.1–A.9 and the manufactured base W₀ with an open inner boundary. Let be an admissible primal variation. Inlet velocity and temperature are fixed, so . Density data enter weakly through prescribed mass inflow, rather than by deleting the discrete inlet density test function. Set and at adjoint inflow, with no extra value for . Outlet traction and heat flux are prescribed.
完整采用 A.2 的通量线性化,包括 ,只含 及其一阶导数。入口质量通量固定,其他乘子为零,入口积分因而消失。出口将 对 求导;固定 后,留下下面的边界系数。它们不含 或 ,因为扩散项尚保留在体内弱积分中。再分部积分一次才恢复 A.8 的强出口条件。
Use the full flux linearization of A.2, including and ; contains only and its first derivatives. At inflow the mass flux is fixed and the other multipliers vanish, so that boundary contribution disappears. At outflow differentiate with respect to , keeping and fixed. The resulting coefficients below do not include or : diffusion remains in the volume weak integral. Integrating it by parts again recovers the strong outlet conditions in A.8.
这里的 是标量边界组合,不是正文离散 Jacobian。弱形式的意思是:对空间中每一个允许的 ,上式都成立。选定一组局部基函数后,依次让 等于各基函数,就产生一行线性方程。连续推导先于有限元装配;程序没有调用正文的有限体积残差或自动微分转置。
Here A is a scalar boundary combination, not the main discrete Jacobian. A weak solution satisfies this identity for every admissible w. After choosing local basis functions, using each one as w produces one matrix row. The continuous derivation precedes finite-element assembly; the implementation never calls the main finite-volume residual or its AD transpose.
局部 P₁/P₂ 空间与稳定化
Local P₁/P₂ spaces and stabilization
把原四边形各分成两个三角形,保留原孔洞与外边界。每个三角形的三个重心坐标记为 。密度变化和质量乘子 用连续分片一次函数;速度、温度及其乘子用连续分片二次函数,后者在顶点和边中点各有一个自由度。不同单元共享这些值,但法向导数可以跳变,因此不强求全域二阶可微。
Split each original quadrilateral into two triangles, preserving the hole and outer polygon. Denote a triangle’s barycentric coordinates by L₁,L₂,L₃. Use continuous piecewise linear functions for density variations and alpha, and continuous piecewise quadratics for velocity, temperature and their multipliers. Quadratic DOFs lie at vertices and edge midpoints. Adjacent cells share values but may have different normal derivatives; global twice differentiability is not required.
连续性方程没有密度扩散。为了抑制这个混合离散中的网格尺度振荡,加入一致的内边惩罚(CIP),只作用于密度试验函数和 的法向导数跳量。令 ,两侧使用同一法向; 是边长, 取固定参考场在边中点的速度。
Continuity has no density diffusion. To suppress mesh-scale oscillations in this mixed discretization, add consistent interior penalty (CIP) only to the normal-derivative jumps of the density test and . Define with the same normal on both sides; is the edge length and the fixed reference velocity at its midpoint.
光滑精确场的导数跨人工内边连续,故这一项为零;它不是给物理方程增加黏性。P₁ 的导数在单元内为常数,代码里的系数因此是 。独立原始弱求解对密度使用相同双线性项;系数不随热源扰动改变,不能漏掉其状态导数。有限网格结果仍依赖 ,所以必须检查减半/加倍后的变化,不能仅以小线性残差宣称稳定化无影响。稀疏线性系统采用 ILU(0) 预处理的重启 GMRES,并重新计算未预处理残差。正文原有 GS 方法不受影响。
A smooth exact field has no derivative jump across an artificial interior edge, so this term vanishes; it adds no physical viscosity to the PDE. P₁ derivatives are constant per cell, giving the code’s coefficient . The independent primal weak solve uses the same bilinear term for density. Its coefficient stays fixed under heating perturbations, while its state derivative is retained. Finite-mesh results still depend on , requiring half/double checks. A small linear residual cannot establish that stabilization has negligible effect. Restarted GMRES uses ILU(0), checking the unpreconditioned residual afterwards. The main lesson’s GS method is unchanged.
怎样决定加密哪里
Where to refine
只看单元内部强残差会漏掉跨边导数跳变。设 ,内边跳量 , 是 A.8–A.9 实际边界条件的残差。采用下面的量指导加密;内部边贡献的一半分配给每侧单元。各分量已经无量纲化,但本实现没有证明这个混合可压缩系统的严格可靠性常数,因此 是诊断和标记指标,不是误差上界。
Interior strong residuals alone miss derivative jumps across edges. Define , the interior jump , and as the residual of the actual boundary conditions in A.8–A.9. Use the following refinement indicator, assigning half an interior-edge contribution to each neighbour. Components are nondimensional, but no rigorous reliability constant is established for this mixed compressible system. So is a diagnostic and marking indicator, not an error bound.
把单元按 从大到小排序,标记累计达到总量 45% 的单元;分裂其边,再补分相邻单元以消除悬挂节点。重新装配、求解,并比较相邻网格的场和热源梯度。所有边仍位于同一个多边形上,避免把几何变化误当作离散收敛。指标验算比装配多用一个 Gauss 点,另行比较每向 4 与 5 点的装配。界面和体内指标必须一起看;弱解不要求分片强残差逐点为零。
Sort cells by and mark enough to carry 45% of the total. Split their edges and subdivide neighbours to remove hanging nodes. Reassemble, solve and compare fields and heating gradients between meshes. All boundary edges remain on the same polygon, separating approximation from geometry change. Diagnostic quadrature uses one more Gauss point than assembly; also compare assemblies with four and five points per direction. Inspect interface and volume indicators together: a weak solution need not have a pointwise zero broken strong residual.
triangulate the original polygon; keep its inner hole
for each refinement cycle:
assemble the continuous weak operator + outlet terms + CIP
solve with ILU(0)-preconditioned GMRES
recompute the true linear residual
integrate cell residuals, interior jumps and boundary residuals
compare the field and heating gradient with the previous mesh
mark cells carrying 45% of the total indicator
subdivide marked edges and close neighbouring triangles
freeze this mesh, source baseline, boundary data and CIP coefficient
solve nonlinear primal at a = 0, +h, -h
compare the weak-adjoint gradient with the central difference真实温度目标与独立原始重算
Actual tracking objective and independent primal reruns
这里与 A.15 的制造参数族不同:只改变外加热源,、入口和出口数据全部固定,不重新制造源项。先在选定网格上用 Newton 与线搜索独立收敛原始弱问题,再以这个数值基态重装伴随,最后求解 两个非线性问题。差分用真实温度目标,不使用预先知道的伴随场。原始残差、目标、伴随右端与热源梯度采用相匹配的求积规则;额外求积点用于验算,不能偷偷改变差分目标。
Unlike the manufactured family in A.15, only added heating changes: S₀ and all inlet/outlet data stay fixed. On the selected mesh, independently converge the nonlinear weak primal with Newton and line search, reassemble the adjoint about that numerical base, then solve the +h and −h nonlinear problems. Differences use the actual temperature objective, with no prescribed exact adjoint. The primal residual, objective, adjoint RHS and heating gradient use matching quadrature; extra diagnostic points must not silently redefine the FD objective.
需要区分两种基态。加密图以解析制造基态 装配,隔离伴随逼近误差;“重算 ±h”则以独立收敛的数值原始基态装配,因而梯度略有差别。弱形式在同一有限元空间上具有离散对偶性,原始与伴随虽然分别按物理公式装配,其矩阵仍应互为转置。差分吻合验证这种实现和总导数,但不能单独证明连续精度;它也不是原正文有限体积的离散伴随测试。
Distinguish the two base states. Refinement uses the analytic manufactured W₀ to isolate adjoint approximation error. Rerun ±h uses an independently converged numerical primal base, giving a slightly different gradient. The weak forms have discrete duality in the same finite-element space: although assembled separately from physical formulas, their matrices should transpose. FD agreement checks this implementation and total derivative, but cannot alone prove continuum accuracy. It is also separate from the main finite-volume discrete-adjoint test.
随页保存的局部弱形式结果
Saved local weak-form results
“重算 ±h”按钮的默认设置(Sutherland、CIP 系数 0.02、每向 4 点装配)在 128 个三角形的初始网格上给出:伴随梯度 ,独立非线性原始重算的中心差分 (),相对差 (伴随以独立收敛的数值原始基态装配,所以与下表第 0 轮以解析基态装配的 略有不同);正负扰动原始解的终止残差为 与 。这一吻合来自同一有限元空间里的离散对偶性,只说明实现与总导数正确;连续精度要看下表的加密记录(随页保存、A.18 同样使用的 Sutherland 参考)。
With its defaults (Sutherland, CIP coefficient 0.02, four-point assembly) the “recompute ±h” button gives, on the 128-triangle initial mesh, adjoint gradient and a central difference of independent nonlinear primal reruns (), a relative discrepancy of (the adjoint is assembled about the independently converged numerical primal base, so it differs slightly from the analytic-base cycle-0 value in the table below); the perturbed primal solves end at residuals and . This agreement comes from discrete duality in one finite-element space and shows only that the implementation and total derivative are right; continuum accuracy needs the refinement record below (saved with the page; the same Sutherland reference used by A.18).
| 轮次Cycle | 三角形Triangles | 自由度DOFs | 热源梯度Heating gradient | 相对变化Relative change | 指标 𝓘Indicator 𝓘 |
|---|---|---|---|---|---|
| 0 | 128 | 842 | -3.759e-3 | — | 4.522e-2 |
| 1 | 151 | 1002 | -4.032e-3 | 6.772e-2 | 3.787e-2 |
| 2 | 204 | 1354 | -4.151e-3 | 2.870e-2 | 3.003e-2 |
| 3 | 269 | 1795 | -4.161e-3 | 2.523e-3 | 2.472e-2 |
| 4 | 371 | 2463 | -4.219e-3 | 1.362e-2 | 1.931e-2 |
| 5 | 512 | 3416 | -4.240e-3 | 4.915e-3 | 1.592e-2 |
| 6 | 707 | 4700 | -4.237e-3 | 5.230e-4 | 1.248e-2 |
| 7 | 983 | 6543 | -4.236e-3 | 4.461e-4 | 9.835e-3 |
| 8 | 1361 | 9047 | -4.249e-3 | 3.190e-3 | 7.471e-3 |
| 9 | 1843 | 12281 | -4.254e-3 | 1.094e-3 | 5.828e-3 |
| 10 | 2653 | 17595 | -4.256e-3 | 5.640e-4 | 4.376e-3 |
已取得的精度,以及还不能声称的结论
Measured accuracy and its limits
固定 Sutherland 默认参数的局部加密,三角形数从 128 增至 2653: 从 0.04522 降至 0.004376,热源梯度从 变为 。末三张网格(1361、1843、2653 个三角形)的梯度为 ,最后两次相对变化约 0.109% 和 0.056%。在 1843 三角形网格上把每向装配求积从 4 点提高到 5 点,梯度相对变化约 ,说明该网格的主要误差不来自求积。中途相对变化曾短暂低于 0.06%,随后又达到 0.32%,所以不能遇到一次小变化就停止并宣布收敛。
With the default Sutherland parameters, local refinement increases triangles from 128 to 2653: falls from 0.04522 to 0.004376 and the heating gradient changes from −0.0037587 to −0.0042562. The last three meshes (1361, 1843, 2653 triangles) give −0.00424914, −0.00425380 and −0.00425620; the last two relative changes are about 0.109% and 0.056%. Increasing assembly quadrature from four to five points per direction on the 1843-triangle mesh changes the gradient by about 1.1×10⁻¹¹ relative, excluding quadrature as the dominant error there. Earlier, a change briefly fell below 0.06% before rising to 0.32%. One small change is not a convergence certificate.
在 1843 三角形网格上, 的梯度分别为 ,总跨度约 0.072%。同网格数值原始基态的伴随梯度约 ,与 的独立原始重算中心差分相差约 (相对)。这两个精度数字的含义不同:本算例的连续梯度目前得到约 相对量级的网格/稳定化一致性支持,而不是 的连续误差保证。常物性和 Sutherland 的制造场另在三层均匀网格上检验;混合场相对误差约为 。
On the 1843-triangle mesh, gives gradients −0.00425264, −0.00425380 and −0.00425572, a spread of about 0.072%. With a numerical primal base on that mesh, the adjoint gradient is about −0.00425276 and agrees with independently reconverged central differences at h=10⁻⁴ to about 2.5×10⁻⁸ relative. These numbers mean different things: this case has roughly 10⁻³ relative mesh/stabilization consistency evidence, not a 10⁻⁸ continuum-error guarantee. Manufactured fields for constant and Sutherland transport are separately checked on three uniform meshes, with mixed-field relative errors about 7.0×10⁻³, 1.6×10⁻³ and 3.6×10⁻⁴.
本节的局部方法覆盖开口内圈、固定边界的光滑二维温度跟踪与热源导数。壁面热流、壁面力、几何导数和一般层流绕流的连续参考仍需各自验证,不能借用这里的精度数字。A.14 的全局方法适合展示光滑制造解,也可与局部加密对照。教学重点仍是正文离散伴随;本节说明为什么独立连续参考本身也必须求解、加密和验证。
This local method covers smooth 2D tracking and heating sensitivities with an open inner boundary and fixed boundary data. Wall heat, wall force, geometry sensitivities and general laminar body-flow references still require their own validation. The global method of A.14 suits smooth manufactured solutions and serves as a contrast to local refinement. The main lesson remains discrete adjoints; this section shows why an independent continuous reference must itself be solved, refined and verified.
A.17 独立的一维限制问题
A.17 An independent one-dimensional restricted problem
下面单独求解单位宽度、无横向变化的平面一维加热问题,使用已有的四变量空间 ODE 射击和连续 ODE 伴随。入口质量流率固定,出口给总应力与零热流;这与本页 O 型域不是同一个边值问题。我们将其三分量守恒状态嵌入四分量 ,检查二维通量在 下恢复同一通量,并用独立重算差分检查 ODE 梯度。
Solve a separate unit-width plane-1D heated problem using the existing four-variable spatial shooting ODE and continuous ODE adjoint. Fixed inlet mass flow and prescribed outlet total stress/zero heat define a different BVP from the O-domain. Embed its state as , verify that 2D flux with reduces to the same flux, and check the ODE gradient with independently reconverged differences.
此限制问题固定 ,;源项只有能量分量 ,默认 。定义轴向应力和净能量通量后可将二阶方程改写为四个一阶方程:
The restricted problem fixes , . Only energy is forced: , with defaults . Define axial stress and net energy flux to rewrite the second-order problem as four first-order equations:
未知初值是 。对这两个初值积分 ,用终点 的 系统做 Newton 射击,再以 RK4 积分连续伴随。初端 固定而 自由,因此在这个无端点目标的例子中,;末端乘子由这两条条件确定。积分步数可切换,并须分别检查射击残差和伴随端点残差。
The unknown initial values are . Integrate for these seeds, use the endpoint matrix in Newton shooting, and integrate the continuous adjoint with RK4. At the inlet, are fixed while are free, so this example without endpoint objectives requires ; these determine the terminal multipliers. Select integration resolution and inspect both shooting and adjoint endpoint residuals.
采用约化 Lagrangian
For the reduced Lagrangian
其端点条件和梯度为:
the endpoint conditions and the gradient are:
连续 ODE 伴随先推导再积分,不是 RK4 算法的离散转置。这里的验证只支持这个限制问题与通量降维,不把它标为完整二维连续伴随已验证。
The continuous ODE adjoint is derived before integration, not obtained by transposing RK4. This validates only the restricted problem and flux reduction, not a complete 2D continuous adjoint.
随页保存的结果(256 步 RK4,常物性):射击边界残差 ,连续伴随端点残差 ,二维通量在 下与一维通量的最大差 。连续梯度 与独立重算差分 相差 。
Saved result (256 RK4 steps, constant transport): shooting boundary residual , continuous-adjoint endpoint residual , and a largest 2D-versus-1D flux difference of for . The continuous gradient and the independently reconverged difference differ by .
A.18 两套伴随是否趋于同一个梯度?
A.18 Do the two adjoints approach the same gradient?
单独通过差分检查,只能说明某套伴随正确求导了它自己的离散目标。这里增加直接的跨方法对比:正文有限体积离散伴随与 A.16 独立连续弱形式,必须先描述同一个物理问题,再各自加密。比较对象是热源幅度 的总梯度,不直接相减维数、位置和尺度不同的伴随系数。
Passing a finite-difference check alone shows that an adjoint differentiates its own discrete objective correctly. This appendix compares the main finite-volume discrete adjoint directly with the independent continuous weak form of A.16. Both must describe the same physical problem before their meshes are refined. Compare the total derivative with respect to heating amplitude a, not coefficient vectors with different dimensions, locations and scaling.
区域始终是原始网格的同一多边形,内圈是开口边界。基态采用第 15 节的光滑制造场; 由该场的物理通量求散度。入口状态和出口牵引/热流在热源扰动时固定。常物性与 Sutherland 分开比较;顶部“真实绕流”、壁面温度或黏度参数的梯度不属于这项实验。独立检查逐点核对双方基态、制造源项与热源函数,防止比较了两个不同的问题。
Keep exactly the original polygonal domain, with an open inner boundary. Use the smooth manufactured base of Section 15, obtaining S₀ from its physical flux divergence. Hold inlet data and outlet traction/heat flux fixed while perturbing heating. Compare constant and Sutherland transport separately. Physical body flow, wall temperature and viscosity derivatives from the main controls are outside this experiment. Independent pointwise checks match the two bases, manufactured sources and heating functions before comparing gradients.
沿用正文的列梯度约定,。为什么比较这两个量?先将离散平衡 对 求导,再用伴随方程消去未知的流场变化 :
Keep the main text’s column-gradient convention, . Why compare these quantities? Differentiate the discrete balance Rh(Uh(a),a)=0 with respect to a, then eliminate the unknown state change dUh/da with the adjoint equation:
本目标没有对 的显式依赖,所以 。体热源仅进入能量残差,保留能量平衡的行有 ;纯入口中被强条件覆盖的行对该热源参数的导数是零。因此不能把所有“边界约束乘子”直接当作连续能量伴随的节点值。程序直接使用实际残差的参数导数,自动保留这种区别。
The objective has no explicit dependence on , so . Heating enters only energy-balance rows, where . Pure-inlet rows replaced by prescribed conditions have zero derivative with respect to this heating parameter. Their boundary-constraint multipliers cannot simply be treated as nodal values of the continuous energy adjoint. Differentiating the actual residual preserves this distinction.
连续式的边界项由 A.8 的入口/出口伴随条件抵消,且本实验的边界数据不随 改变。这一步正是两条路线可比较的前提;若扰动的是入口或出口数据,就必须重新加入相应的边界梯度,不能沿用这里的纯体积分。
The inflow/outflow adjoint conditions of A.8 cancel the state-variation boundary terms, and boundary data do not vary with a in this experiment. This is essential for comparing the routes. Perturbing inlet or outlet data would require the corresponding boundary-gradient terms; the volume-only formula would no longer suffice.
有限体积每一级先收敛实际非线性流场,再求该流场上的精确离散伴随。连续参考在解析基态上独立装配,以隔离伴随空间逼近误差。因此有限体积比较中同时含有原始场误差;表中另外列出守恒量相对误差。只有这些误差都减小时,才有依据讨论共同极限。连续参考也只是有限网格的数值解,不是已知精确梯度。
On each finite-volume mesh, first converge the actual nonlinear primal, then solve its exact discrete adjoint. Assemble the independent continuous reference about the analytic base to isolate adjoint approximation error. The FV comparison therefore also includes primal error; the table reports a relative conservative-state error. A common limit can only be assessed as both errors decrease. The continuous reference is itself a numerical approximation, not a known exact gradient.
格心 是解析守恒量的单元平均,格点则取解析节点值;两种格式的 按各自控制体计算。图中的参考水平线来自实际求解。必须同时看连续参考最近几次加密的变化,不把 当作精确误差,也不根据单次下降宣称收敛。粗网格可能给出错误的梯度符号;二阶原始重构也不保证这个带角点伴随问题的目标梯度立即呈二阶收敛。
For cells, U* is the analytic conservative cell average; for nodes it is the analytic nodal value. Each scheme uses its own control-volume h. The reference line is computed by an actual solve. Inspect recent continuous-reference changes as well: is a difference from a numerical reference, not an exact error, and one decrease is not a convergence certificate. A coarse mesh can even give the wrong gradient sign. Second-order primal reconstruction does not guarantee immediate second-order goal-gradient convergence in this cornered adjoint problem.
fix domain, base forcing, boundary data, transport and heating direction
for each FV scheme, order and refinement level:
converge the unchanged nonlinear FV residual
assemble its exact sparse Jacobian using disjoint derivative seeds
solve the transposed system; check against the original reverse product
g_FV = j_a + R_ha^⊤ psi
independently refine the continuous weak adjoint
g_CA = -integral(chi * heating)
compare signed gradients, relative differences and primal errors
optionally reconverge FV primal at +h and -h for a same-mesh FD check这一加密实验为了速度显式组装稀疏精确 Jacobian,用 ILU(0) 预处理 GMRES 求解。它不改变正文的通量、重构、边界或目标,也不把一阶近似当作精确导数。按残差依赖关系为互不重叠的列分组,一次前向传播得到多列,再恢复各行。测试将装配后的矩阵乘积、转置乘积和最终梯度与正文 matrix-free 实现逐项比较。正文原有 GS 教学与 matrix-free 实现继续保留。
For affordable refinement this experiment explicitly assembles the exact sparse Jacobian and uses ILU(0)-preconditioned GMRES. It changes neither the lesson’s fluxes, reconstruction, boundary closure nor objective, and does not substitute a first-order approximation for the exact derivative. Group columns whose residual dependencies do not overlap, propagate one combined seed, then recover each row. Tests compare assembled forward/transpose products and final gradients with the main matrix-free implementation. The main GS lesson and matrix-free solver remain available.
细网格上的 GMRES 使用更大的 Krylov 子空间:此面板默认最多保留 1000 个向量,也可降低到 100/300/500,或增加到 1500。实际向量数不超过系统维数。重启太早可能反复丢失对收敛重要的方向;增大子空间会增加内存和正交化计算,但不改变待求方程。表中显示该结果实际采用的向量上限;未通过真实残差检查的计算不会加入梯度曲线。
Use a larger Krylov space on refined meshes: this panel defaults to at most 1000 vectors, with 100/300/500 and 1500 also available. The actual limit does not exceed the system dimension. Restarting too early can repeatedly discard directions important to convergence. A larger space costs memory and orthogonalization work without changing the equations. The result table identifies the vector limit actually used; solves failing the true-residual check are excluded from the gradient curves.
随页记录含有基网格(24 个控制体)上的有限差分核对:8 个记录覆盖格心/格点、一阶/二阶与两种物性,伴随梯度与重新收敛的中心差分(步长 )的相对差在 到 之间。这只说明每个离散梯度对它自己的离散目标是精确的;它们是否趋向同一极限,要看下面的加密比较。
The saved records include finite-difference checks on the base mesh (24 volumes): 8 records covering cells/nodes, first/second order and both transport laws, where the adjoint gradient and reconverged central differences (step ) differ by to relative. This shows only that each discrete gradient is exact for its own discrete objective; whether the gradients approach a common limit is the refinement comparison below.
如何读随页结果(连续参考为随页保存的 2657/2653 三角形网格):6144 个控制体的格心二阶,在常物性/Sutherland 下与连续参考分别相差约 0.103%/0.0463%;连续参考最近一次加密自身变化约 0.0331%/0.0564%。格点二阶在 4224 个控制体时相差约 0.268%/0.299%;格点在入口/出口交界节点保留能量平衡(格点页第 4 节)。
Reading the saved results (the continuous reference is the saved 2657 / 2653-triangle mesh): at 6144 control volumes, second-order cells differ from the continuous reference by about 0.103% / 0.0463% for constant / Sutherland transport; the reference's latest refinement changes it by about 0.0331% / 0.0564%. Second-order nodes at 4224 volumes differ by about 0.268% / 0.299%; nodes keep the energy balance at inlet/outlet junction nodes (Section 4 of the node page).
这不是严格误差上界或共同极限的证明。格点二阶在 1088 个控制体时的差距约为 0.148%/0.187%,反而小于最细层,显示不同误差项可能抵消。应同时看有符号梯度、相邻层变化和原始误差,不能挑选最接近参考的一层宣布收敛,也不能用差分吻合或原始场精度替代目标梯度的网格收敛。一阶也不能省略:最细层上,格心一阶相差约 12.5%/5.17%,格点一阶约 6.91%/1.30%。参考本身仍有有限元、稳定化及局部加密误差。
These differences are neither strict error bounds nor proof of a common limit. At 1088 control volumes, second-order nodal differences are about 0.148% / 0.187%, smaller than on the finest mesh, indicating possible cancellation among error terms. Inspect signed gradients, successive-level changes and primal errors together; do not declare convergence by selecting the closest level. FD agreement and primal accuracy cannot replace goal-gradient refinement. First order matters too: on the finest level, first-order cells differ by about 12.5% / 5.17% and first-order nodes by about 6.91% / 1.30%. The reference also retains finite-element, stabilization and local-refinement errors.
格点一阶常物性曲线在 80、288、1088 个控制体上几乎不动(相对差 11.6%、12.5%、10.6%,这里用再加密到 14842 个三角形的连续参考;相对它,4224 个控制体为 6.90%/1.29%,而上段相对随页参考为 6.91%/1.30%),看上去像停滞。离线的更细网格(refinement_level6_summary.json,同一残差、同一目标)说明它没有停住:4224、16640、66048 个控制体的差距依次为 6.90%、4.15%、2.52%,最后两层的表观阶约 0.72,原始场的表观阶约 0.85。1–3 级几乎不变,可能是粗网格上几种误差项部分抵消(这里没有分解验证);更细的网格才显示单调下降,而表观阶仍小于 1,说明尚未进入渐近区。Sutherland 的一阶格点在同样的网格上是 1.29%、0.786%、0.260%(最后两层表观阶约 1.60)。
The constant-transport first-order nodal curve barely moves at 80, 288, 1088 volumes (relative differences 11.6%, 12.5%, 10.6%, against the reference refined to 14842 triangles; relative to it the 4224-volume mesh gives 6.90% / 1.29%, where the paragraph above, relative to the saved reference, gives 6.91% / 1.30%) and looks stalled. Finer offline meshes (refinement_level6_summary.json, same residual and objective) show that it is not: at 4224, 16640, 66048 volumes the differences are 6.90%, 4.15%, 2.52%, with an apparent order of about 0.72 over the last two levels (primal field about 0.85). Levels 1–3 hardly change, possibly because several error terms partly cancel on coarse meshes (not decomposed here); only finer meshes show a monotone decrease, and the apparent order is still below 1, so the asymptotic range has not been reached. Sutherland first-order nodes on the same meshes give 1.29%, 0.786%, 0.260% (apparent order about 1.60 over the last two levels).
“重新计算曲线”与“重算连续参考”均在本机后台实际求解;停止保留已完成级别。切换物性时不会混用结果。差分按钮只检验选中有限体积网格的总导数;它不能代替跨网格对比。若参考随加密仍显著变化,或某条离散曲线停滞,页面如实保留结果,不标记为“已验证共同极限”。
Recompute curve and Recompute reference perform real local background solves; Stop retains completed levels. Results from different transport laws are kept separate. The FD button only verifies the selected finite-volume mesh’s total derivative and cannot replace refinement. If the reference still changes or a discrete curve stagnates, retain that evidence without labelling a common limit as verified.
一维限制问题只验证其自身的连续 ODE 梯度和二维通量的降维关系。A.12–A.13 的二维 Green 恒等式与边界消项检查验证推导本身,并由 A.14–A.16 与 A.18 的独立二维边值求解和梯度检查补充。正文离散伴随的验证继续使用精确转置、全重算差分、复数步长与网格加密。
The restricted problem validates its own continuous ODE gradients and flux reduction. The 2D Green identity and boundary-cancellation checks of A.12–A.13 verify the derivation itself, and are supplemented by the independent 2D BVP solves and gradient checks in A.14–A.16 and A.18. Discrete-adjoint verification continues to use exact transpose products, reconverged differences, complex steps and mesh refinement.
附录 B:二维黏性局部求导
Appendix B: local 2D viscous differentiation
B.1 一个面包括整个邻域
B.1 A face includes its whole stencil
选择内部面或边界面,查看真实局部通量、方向导数和反向贡献。固定状态方向为 ,面种子为 。局部点积仅检查状态路径,不能代替参数和几何的全重算验证;B.4 把同一个面点的反向一直推到黏度参数,并用差分核对。
Select an interior or boundary face to inspect flux, directions and reverse contributions. The state direction is and the face seed is . Local dot products check state paths only, not reconverged parameter and geometry derivatives; B.4 follows the reverse sweep at one Gauss point down to the viscosity parameter and checks it by differences.
B.2 绝热与常物性不能删错路径
B.2 Do not erase the wrong derivative paths
常物性只删除 ,不删除对参考黏度的导数。绝热壁将法向热流目标严格置零,但速度梯度、剪应力、压力及内域导热仍存在。对一个被覆盖行的零种子,也不能误认为该节点对所有邻域通量都没有贡献。
Constant transport removes , not reference-viscosity derivatives. An adiabatic wall has exactly zero normal heat flux but still has velocity gradients, shear, pressure and interior conduction. Zero seed on an overwritten row does not remove that node's contributions to neighbouring fluxes.
用基网格层流绕流(格心、二阶、常物性、x 合力目标)核对:绝热壁时,壁温参数不进入任何残差或目标,,恰好为零;常物性只去掉 ,对参考黏度的导数仍是 。换成等温壁,:壁温直接进入壁面压力 和温度梯度惩罚,并经流场响应影响合力。绝热壁上的热流目标恒为 ,其全部参数与坐标梯度的最大绝对值为 。
Check with the base-mesh laminar flow (cells, second order, constant transport, x-force objective): at an adiabatic wall the wall-temperature parameter enters no residual or objective, so exactly; constant transport removes only , and remains. With an isothermal wall : the wall temperature enters the wall pressure and the temperature-gradient penalty directly, and the force through the flow response. The heat objective on an adiabatic wall is identically , and the largest absolute value of all its parameter and coordinate gradients is .
B.3 一个单面应力的手算核对
B.3 A hand check of one face stress
取纯剪切局部速度 ,,温度为常数,面法向 。速度散度为零,只有剪应力不为零:
Take local pure shear , , constant temperature and face normal . Divergence vanishes and only shear stress remains:
给剪应力一个反向权重 ,应得到 与 。这个小例子专门检查应力本构;页面的局部面交互再加入真实重构、边界与求积,检查整条路径。
A reverse shear-stress weight gives and . This small example checks the constitutive law; the interactive face then includes reconstruction, boundaries and quadrature.
B.4 一个真实面点的完整数值链
B.4 A complete numerical chain at one real Gauss point
B.3 只核对了本构关系。下面把默认基准(格心、二阶、常物性)第一个内部面 f0(左 0、右 1)的第一个 Gauss 点从头算到尾:先由两侧二次多项式得到迹和原始量梯度,再加跳跃修正、求应力与热流、组成通量;然后给面通量一个种子,逐层反传。所有数值由本页代码在构建时算出,check_fixall.js 用有限差分核对反向各层。
B.3 checks only the constitutive law. Below, the first Gauss point of the first interior face f0 (left 0, right 1) of the default baseline (cells, second order, constant transport) is followed end to end: traces and primitive gradients from the two quadratic polynomials, the jump correction, stress and heat flux, the flux; then a seed on the face flux is reversed layer by layer. Every number is computed by this page's code at build time; check_fixall.js checks each reverse layer by finite differences.
| 步骤Step | 量Quantity | 数值Value |
|---|---|---|
| 几何Geometry | x_q, n, w_q | (8.720e-1, 7.817e-1), (-7.660e-1, 6.429e-1), 2.558e-1 |
| 左迹Left trace | (ρ, u, v, T) | (1.021e+0, 2.398e-1, 6.346e-2, 1.034e+0) |
| 右迹Right trace | (ρ, u, v, T) | (1.021e+0, 2.397e-1, 6.348e-2, 1.033e+0) |
| 跳跃修正Jump correction | 2(u_R − u_L) x_LR/(x_LR·x_LR) | (2.530e-4, -2.201e-4) |
| 修正后梯度Corrected gradients | Gu, Gv | (8.096e-3, 9.908e-3), (-6.283e-3, 2.841e-4) |
| 修正后梯度Corrected gradient | GT | (1.239e-2, 1.490e-2) |
| 物性Transport | μ, κ | 2.000e-1, 9.722e-1 |
| 应力Stress | τxx, τxy, τyy | (2.121e-3, 7.250e-4, -1.004e-3) |
| 牵引与热流Traction and heat | τn, qn | (-1.159e-3, -1.201e-3), -7.947e-5 |
| 对流通量Convective flux | (-1.453e-1, -8.431e-1, 6.686e-1, -5.294e-1) | |
| 黏性通量Viscous flux | (0, -1.159e-3, -1.201e-3, -2.745e-4) | |
| 点通量Point flux | (-1.453e-1, -8.419e-1, 6.698e-1, -5.292e-1) | |
| 面通量(两点加权和)Face flux (weighted over both points) | H_f | (-7.306e-2, -4.283e-1, 3.413e-1, -2.648e-1) |
反向从面种子 开始(分量记为 ),先乘这个点的权重 。黏性通量以负号进入 ,按第 9 节的公式依次得到:
The reverse sweep starts from the face seed (components ), multiplied by this point's weight . The viscous flux enters with a minus sign, so the formulas of Section 9 give in turn:
| 反向步骤Reverse step | 量Quantity | 数值Value |
|---|---|---|
| 牵引权重Traction weight | (4.608e-2, -1.104e-1) | |
| 热流权重Heat-flux weight | 1.279e-1 | |
| 速度(黏性功)Velocity (viscous work) | (1.482e-4, 1.536e-4) | |
| 应力分量Stress components | (-3.530e-2, 1.142e-1, -7.100e-2) | |
| 速度梯度Velocity gradients | (5.409e-5, 2.285e-2), (2.285e-2, -1.423e-2) | |
| 温度梯度Temperature gradient | (9.526e-2, -7.994e-2) | |
| 黏度(应力与导热两条路径)Viscosity (stress and conduction paths) | 3.452e-4 |
到这里仍是一个点上的局部量。接下来 经过跳跃修正分到两侧迹和两侧梯度,再经过原始量转换和两套二次拟合 回到重构邻域的全部状态。页面的反向计算图对两个 Gauss 点一起做完这一步:这个面的反向贡献写入 19 个状态(左、右控制体及其拟合邻域的并集),绝对值最大的是状态 0、1、23。所以一个面的伴随贡献不局限于 L、R 两点,这正是 B.1 的点积检查所覆盖的内容。
So far these are local quantities at one point. Next, is split by the jump correction into both traces and both gradients, then passes through the primitive conversion and the two quadratic fits to every state of the reconstruction stencils. The page's reverse graph completes this for both Gauss points: this face writes reverse contributions into 19 states (the union of the left and right volumes and their stencils), the largest in magnitude being states 0, 1, 23. A face's adjoint contribution is therefore not confined to L and R; this is exactly what the dot-product check of B.1 covers.
附录 C:完整矩阵与 LU
Appendix C: complete matrix and LU
C.1 基网格可以把整个矩阵看清楚
C.1 Inspect the complete base-grid matrix
逐个单位状态种子形成精确矩阵的列,随后对完整转置作部分主元 LU。这里明确存储矩阵,不是 matrix-free。主元置换与 GS 预处理矩阵不同;上三角因子与守恒状态向量也不是同一个 U。
Unit state seeds form exact matrix columns, then partial-pivot LU factors the full transpose. This explicitly stores a matrix and is not matrix-free. Pivot permutation differs from the GS preconditioner, and the upper factor is not the conservative state vector.
for j in state slots: A[:,j] = exact_JVP(unit(j)) M = transpose(A) for k: choose pivot, swap complete rows, store elimination multipliers y = forward_solve(L_LU, permute(-jU)) psi = back_solve(U_LU, y) check norm(exact_VJP(psi) + jU)
滑块显示当前消元矩阵;解向量表显示完整求解的最终结果。重构使非零块超过最近邻。小网格可用这个方法交叉检查 GMRES,但稠密存储与立方运算量不适合据此推广到大规模 RANS。
The slider shows the elimination matrix at a stage; solution tables show the completed solve. Reconstruction extends nonzero blocks beyond nearest neighbours. This cross-checks GMRES on small meshes but dense storage and cubic work do not scale to large RANS.
随页保存的结果:默认基准的 96 × 96 转置矩阵中,576 个 4 × 4 块有 432 个非零;部分主元消元后有 40 行离开了原来的位置。LU 解的真残差 (右端范数 ),与第 17 节 FGMRES 解的最大分量差为 ,而 :两者在迭代容差内一致。
Saved result: in the 96 × 96 transposed matrix of the default baseline, 432 of the 576 4 × 4 blocks are nonzero; after partial pivoting 40 rows are out of their original order. The LU solution has true residual (right-hand-side norm ) and differs from the Section 17 FGMRES solution by at most per component, with : the two agree within the iterative tolerance.
C.2 从消元操作理解 LU
C.2 Understand LU through elimination
继续使用两未知量例子。第二行加第一行的一半,消掉左下角的 ;消元倍率是 ,因为标准公式写成“第二行减去倍率乘第一行”。上三角方程先解第二个未知量,再回代第一个:
Continue the two-unknown example. Add half the first row to the second to eliminate -1. The stored multiplier is -1/2 because elimination subtracts the multiplier times the pivot row. Solve the second unknown, then substitute backward:
一般矩阵需要换行选择较大的主元,以避免除以零或很小的数。置换同时作用于右端;对于已经存储的消元倍率,也必须保持相同行置换。完整矩阵的第 j 列由单位向量 的 JVP 得到,这里 表示基向量,不是能量密度 e。
General matrices use row pivoting to avoid zero or small divisors. Apply the permutation to the right-hand side and consistently to stored elimination multipliers. A JVP on unit basis vector forms column j; this is not energy density e.
前代、回代完成后仍要把解代回原始伴随算子,检查 。这样才能发现置换、转置或装配错误,而不是只确认“分解函数返回了一个数组”。
After both substitutions, evaluate the original adjoint residual . This checks permutation, transposition and assembly, rather than merely that factorization returned an array.
查看完整数值实现Inspect the complete numerical implementation
// FILE: ../q1d/reconstruction/math.js
(function(root){'use strict';
const gamma=1.4,alpha=2,zeros=n=>Array(n).fill(0),dot=(a,b)=>a.reduce((s,x,i)=>s+x*b[i],0),norm=a=>Math.sqrt(dot(a,a));
const real={c:x=>x,add:(a,b)=>a+b,sub:(a,b)=>a-b,mul:(a,b)=>a*b,div:(a,b)=>a/b,pow:(a,p)=>a**p,sin:Math.sin,cos:Math.cos};
function tape(){const val=[],pa=[],pb=[],da=[],db=[],inputs=[];function node(v,a=-1,b=-1,x=0,y=0){const i=val.length;val.push(v);pa.push(a);pb.push(b);da.push(x);db.push(y);return i;}const op={c:x=>node(x),input:x=>{const i=node(x);inputs.push(i);return i;},add:(a,b)=>node(val[a]+val[b],a,b,1,1),sub:(a,b)=>node(val[a]-val[b],a,b,1,-1),mul:(a,b)=>node(val[a]*val[b],a,b,val[b],val[a]),div:(a,b)=>node(val[a]/val[b],a,b,1/val[b],-val[a]/val[b]**2),pow:(a,p)=>node(val[a]**p,a,-1,p*val[a]**(p-1)),sin:a=>node(Math.sin(val[a]),a,-1,Math.cos(val[a])),cos:a=>node(Math.cos(val[a]),a,-1,-Math.sin(val[a]))};
function forward(seed){const d=new Float64Array(val.length);inputs.forEach((i,k)=>d[i]=seed[k]||0);for(let i=0;i<val.length;i++){if(pa[i]>=0)d[i]+=da[i]*d[pa[i]];if(pb[i]>=0)d[i]+=db[i]*d[pb[i]];}return d;}
function reverse(outputs,seeds){const w=new Float64Array(val.length);outputs.forEach((i,k)=>w[i]+=seeds[k]);for(let i=val.length-1;i>=0;i--){if(pa[i]>=0)w[pa[i]]+=da[i]*w[i];if(pb[i]>=0)w[pb[i]]+=db[i]*w[i];}return inputs.map(i=>w[i]);}
return {op,val,forward,reverse};}
const complex={c:x=>[x,0],add:(a,b)=>[a[0]+b[0],a[1]+b[1]],sub:(a,b)=>[a[0]-b[0],a[1]-b[1]],mul:(a,b)=>[a[0]*b[0]-a[1]*b[1],a[0]*b[1]+a[1]*b[0]],div:(a,b)=>{const d=b[0]**2+b[1]**2;return [(a[0]*b[0]+a[1]*b[1])/d,(a[1]*b[0]-a[0]*b[1])/d];},pow:(a,p)=>{const r=Math.hypot(...a)**p,t=p*Math.atan2(a[1],a[0]);return [r*Math.cos(t),r*Math.sin(t)];},sin:a=>[Math.sin(a[0])*Math.cosh(a[1]),Math.cos(a[0])*Math.sinh(a[1])],cos:a=>[Math.cos(a[0])*Math.cosh(a[1]),-Math.sin(a[0])*Math.sinh(a[1])]};
function gmres(A,b,P=x=>x.slice(),tol=1e-11,max=b.length){const n=b.length,beta=norm(b);if(beta<=tol)return {x:zeros(n),residual:beta,history:[beta],states:[zeros(n)],converged:true};const V=[b.map(x=>x/beta)],Z=[],H=Array.from({length:max+1},()=>zeros(max)),cs=zeros(max),sn=zeros(max),g=zeros(max+1),history=[beta],estimates=[beta],states=[zeros(n)];g[0]=beta;let m=0;
for(let j=0;j<max;j++){Z.push(P(V[j]));let w=A(Z[j]);for(let pass=0;pass<2;pass++)for(let i=0;i<=j;i++){const h=dot(V[i],w);H[i][j]+=h;w=w.map((x,k)=>x-h*V[i][k]);}H[j+1][j]=norm(w);const nextnorm=H[j+1][j];if(nextnorm>1e-16)V.push(w.map(x=>x/nextnorm));for(let i=0;i<j;i++){const a=H[i][j],b=H[i+1][j];H[i][j]=cs[i]*a+sn[i]*b;H[i+1][j]=-sn[i]*a+cs[i]*b;}const a=H[j][j],bb=H[j+1][j],r=Math.hypot(a,bb);cs[j]=r?a/r:1;sn[j]=r?bb/r:0;H[j][j]=r;H[j+1][j]=0;g[j+1]=-sn[j]*g[j];g[j]*=cs[j];m=j+1;estimates.push(Math.abs(g[j+1]));const yy=zeros(m);for(let ii=m-1;ii>=0;ii--){let a=g[ii];for(let kk=ii+1;kk<m;kk++)a-=H[ii][kk]*yy[kk];yy[ii]=a/H[ii][ii];}const xx=zeros(n);for(let ii=0;ii<m;ii++)for(let kk=0;kk<n;kk++)xx[kk]+=Z[ii][kk]*yy[ii];states.push(xx);history.push(norm(A(xx).map((v,i)=>v-b[i])));if(history.at(-1)<tol||nextnorm<=1e-16)break;}
const y=zeros(m);for(let i=m-1;i>=0;i--){let a=g[i];for(let j=i+1;j<m;j++)a-=H[i][j]*y[j];y[i]=a/H[i][i];}const x=zeros(n);for(let i=0;i<m;i++)for(let j=0;j<n;j++)x[j]+=Z[i][j]*y[i];const residual=norm(A(x).map((z,i)=>z-b[i]));return {x,residual,history,estimates,states,converged:residual<=Math.max(tol*5,1e-13)};}
const api={real,complex,tape,gmres,dot,norm};if(typeof module!=='undefined')module.exports=api;else root.QMath=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: ../experiments/numerics.js
/* Small educational systems: matrix-free exact products; explicit matrices only for LU/conditioning diagnostics. */
(function(root){'use strict';
const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath;
const {norm,dot}=Q,zeros=n=>Array(n).fill(0),transpose=A=>A[0].map((_,j)=>A.map(r=>r[j])),mv=(A,x)=>A.map(r=>dot(r,x));
function factor(A){const n=A.length,lu=A.map(r=>r.slice()),p=Array.from({length:n},(_,i)=>i),scale=Math.max(...A.flat().map(Math.abs));for(let k=0;k<n;k++){let j=k;for(let i=k+1;i<n;i++)if(Math.abs(lu[i][k])>Math.abs(lu[j][k]))j=i;if(!Number.isFinite(lu[j][k])||Math.abs(lu[j][k])<1e-14*scale)throw Error('Singular LU pivot '+k);[lu[j],lu[k]]=[lu[k],lu[j]];[p[j],p[k]]=[p[k],p[j]];for(let i=k+1;i<n;i++){lu[i][k]/=lu[k][k];for(let j=k+1;j<n;j++)lu[i][j]-=lu[i][k]*lu[k][j];}}return {lu,p};}
function solve(F,b){const {lu,p}=F,n=b.length,x=p.map(i=>b[i]);for(let i=0;i<n;i++)for(let j=0;j<i;j++)x[i]-=lu[i][j]*x[j];for(let i=n-1;i>=0;i--){for(let j=i+1;j<n;j++)x[i]-=lu[i][j]*x[j];x[i]/=lu[i][i];}return x;}
function assemble(L,n){const A=Array.from({length:n},()=>zeros(n));for(let j=0;j<n;j++){const e=zeros(n);e[j]=1;L.jvp(e).R.forEach((v,i)=>A[i][j]=v);}return A;}
function limiter(a,b,kind='minmod',eps=.01){if(kind==='first')return {s:0,da:0,db:0,branch:'first',margin:1};if(kind==='none')return {s:(a+b)/2,da:.5,db:.5,branch:'unlimited',margin:1};if(kind==='smooth'){const den=a*a+b*b+eps*eps,num=a*b*(a+b);return {s:num/den,da:((2*a*b+b*b)*den-2*a*num)/(den*den),db:((a*a+2*a*b)*den-2*b*num)/(den*den),branch:'smooth',margin:1};}const margin=Math.min(Math.abs(a),Math.abs(b),Math.abs(Math.abs(a)-Math.abs(b)));if(a*b<=0)return {s:0,da:0,db:0,branch:'zero',margin};return Math.abs(a)<=Math.abs(b)?{s:a,da:1,db:0,branch:'left',margin}:{s:b,da:0,db:1,branch:'right',margin};}
function slope(o,a,b,kind,eps,mode='full'){const r=limiter(o.value(a),o.value(b),kind,eps),{c,add,mul,sub,div}=o;if(mode==='frozen'){const d=(o.value(a)+o.value(b))/2,phi=Math.abs(d)>1e-15?r.s/d:0;return add(c(r.s),mul(c(phi/2),add(sub(a,c(o.value(a))),sub(b,c(o.value(b))))));}if(kind==='smooth'){const den=add(add(mul(a,a),mul(b,b)),c(eps*eps));return div(mul(mul(a,b),add(a,b)),den);}return add(c(r.s),add(mul(c(r.da),sub(a,c(o.value(a)))),mul(c(r.db),sub(b,c(o.value(b))))));}
function operators(U,p,evalFn,mode='full'){const t=Q.tape();t.op.value=i=>t.val[i];const ui=U.map(t.op.input),pi=p.map(t.op.input),r=evalFn(t.op,ui,pi,mode);return {R:r.R.map(i=>t.val[i]),J:t.val[r.J],jvp:(v,dp=zeros(p.length))=>{const z=t.forward([...v,...dp]);return {R:r.R.map(i=>z[i]),J:z[r.J]};},vjp:(w,j=0)=>{const z=t.reverse([...r.R,r.J],[...w,j]);return {U:z.slice(0,U.length),p:z.slice(U.length)};}};}
const real={...Q.real,value:x=>x};
function gsMatrix(P,sweeps=2){const n=P.length,B=Array.from({length:n},()=>zeros(n));for(let k=0;k<n;k++){const b=zeros(n),x=zeros(n);b[k]=1;for(let s=0;s<sweeps;s++)for(let i=0;i<n;i++){let v=b[i];for(let j=0;j<n;j++)if(i!==j)v-=P[i][j]*x[j];if(Math.abs(P[i][i])<1e-14)throw Error('Zero GS diagonal');x[i]=v/P[i][i];}x.forEach((v,i)=>B[i][k]=v);}return B;}
function compare(L,P,rhs,adj=false,omega=.7,preAction=null){const n=rhs.length,A=assemble(L,n),AA=adj?transpose(A):A,F=factor(AA),exact=solve(F,rhs),B0=preAction?transpose(Array.from({length:n},(_,j)=>{const e=zeros(n);e[j]=1;return preAction(e);})):gsMatrix(P),B=adj?transpose(B0):B0,op=v=>adj?L.vjp(v).U:L.jvp(v).R,pre=v=>mv(B,v),gm=Q.gmres(op,rhs,pre,1e-11,n);let x=zeros(n),history=[],reason='limit';for(let k=0;k<250;k++){const ax=op(x),r=rhs.map((b,i)=>b-ax[i]),rn=norm(r);history.push(rn);if(rn<1e-10){reason='converged';break;}if(!Number.isFinite(rn)||rn>Math.max(1,norm(rhs))*1e8){reason='growth';break;}const d=pre(r);x=x.map((v,i)=>v+omega*d[i]);}
const inv=transpose(Array.from({length:n},(_,j)=>{const e=zeros(n);e[j]=1;return solve(F,e);})),ni=M=>Math.max(...M.map(r=>r.reduce((s,v)=>s+Math.abs(v),0))),cond=ni(AA)*ni(inv),M=AA.map((_,i)=>AA[0].map((_,j)=>(i===j?1:0)-omega*B[i].reduce((s,v,k)=>s+v*AA[k][j],0)));return {exact,luResidual:norm(op(exact).map((v,i)=>v-rhs[i])),gm,gs:{x,history,reason},conditionInfinity:cond,iterationNormInfinity:ni(M),A};}
const api={...Q,zeros,transpose,mv,factor,solve,assemble,limiter,slope,operators,real,gsMatrix,compare};if(typeof module!=='undefined')module.exports=api;else root.EduNumerics=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: ../e2/core_euler.js
/* Smooth 2-D Euler: a scalar-operation tape gives exact matrix-free JVP/VJP.
Scalar face algebra also runs with independent complex arithmetic. */
(function(root){'use strict';
const gamma=1.4,alpha=2,zeros=n=>Array(n).fill(0),dot=(a,b)=>a.reduce((s,x,i)=>s+x*b[i],0),norm=a=>Math.sqrt(dot(a,a));
const real={c:x=>x,add:(a,b)=>a+b,sub:(a,b)=>a-b,mul:(a,b)=>a*b,div:(a,b)=>a/b,pow:(a,p)=>a**p,sin:Math.sin,cos:Math.cos};
function tape(){const val=[],pa=[],pb=[],da=[],db=[],inputs=[];function node(v,a=-1,b=-1,x=0,y=0){const i=val.length;val.push(v);pa.push(a);pb.push(b);da.push(x);db.push(y);return i;}const op={c:x=>node(x),input:x=>{const i=node(x);inputs.push(i);return i;},add:(a,b)=>node(val[a]+val[b],a,b,1,1),sub:(a,b)=>node(val[a]-val[b],a,b,1,-1),mul:(a,b)=>node(val[a]*val[b],a,b,val[b],val[a]),div:(a,b)=>node(val[a]/val[b],a,b,1/val[b],-val[a]/val[b]**2),pow:(a,p)=>node(val[a]**p,a,-1,p*val[a]**(p-1)),sin:a=>node(Math.sin(val[a]),a,-1,Math.cos(val[a])),cos:a=>node(Math.cos(val[a]),a,-1,-Math.sin(val[a]))};
function forward(seed){const d=new Float64Array(val.length);inputs.forEach((i,k)=>d[i]=seed[k]||0);for(let i=0;i<val.length;i++){if(pa[i]>=0)d[i]+=da[i]*d[pa[i]];if(pb[i]>=0)d[i]+=db[i]*d[pb[i]];}return d;}
function reverse(outputs,seeds){const w=new Float64Array(val.length);outputs.forEach((i,k)=>w[i]+=seeds[k]);for(let i=val.length-1;i>=0;i--){if(pa[i]>=0)w[pa[i]]+=da[i]*w[i];if(pb[i]>=0)w[pb[i]]+=db[i]*w[i];}return inputs.map(i=>w[i]);}
return {op,val,forward,reverse};}
const complex={c:x=>[x,0],add:(a,b)=>[a[0]+b[0],a[1]+b[1]],sub:(a,b)=>[a[0]-b[0],a[1]-b[1]],mul:(a,b)=>[a[0]*b[0]-a[1]*b[1],a[0]*b[1]+a[1]*b[0]],div:(a,b)=>{const d=b[0]**2+b[1]**2;return [(a[0]*b[0]+a[1]*b[1])/d,(a[1]*b[0]-a[0]*b[1])/d];},pow:(a,p)=>{const r=Math.hypot(...a)**p,t=p*Math.atan2(a[1],a[0]);return [r*Math.cos(t),r*Math.sin(t)];},sin:a=>[Math.sin(a[0])*Math.cosh(a[1]),Math.cos(a[0])*Math.sinh(a[1])],cos:a=>[Math.cos(a[0])*Math.cosh(a[1]),-Math.sin(a[0])*Math.sinh(a[1])]};
function mesh(level=1){ // level 1 is indexed-identical to the scalar pages
// Level 1: angles t_j=2*pi*j/8. Ring 0 samples the body curve, ring 2 the circle of radius 1.9, ring 1 blends them.
// Level l>1 subdivides level l-1 uniformly: every edge is halved at its midpoint, every quadrilateral gains its
// vertex average and every triangle is split through its edge midpoints. The body and the far boundary therefore
// stay the two level-1 octagons, the domain never changes, and the mesh size halves from level to level.
if(!Number.isInteger(level)||level<1||level>6)throw Error('Unsupported mesh level');
let X=[],L=1;const mid=(a,b)=>[.5*(a[0]+b[0]),.5*(a[1]+b[1])];
for(let r=0;r<=2;r++){const k=r,z=k<=1?.45*k:.45+.55*(k-1);for(let j=0;j<8;j++){const t=2*Math.PI*j/8,rad=.55*(1+.28*Math.cos(t)+.12*Math.sin(2*t)),b=[.1+rad*Math.cos(t),-.04+rad*Math.sin(t)],o=[1.9*Math.cos(t),1.9*Math.sin(t)];X.push(b.map((x,d)=>(1-z)*x+z*o[d]));}}
for(let l=1;l<level;l++){const nt=8*L,old=(r,j)=>X[r*nt+(j%nt)],Y=[];
for(let R=0;R<=4*L;R++)for(let J=0;J<2*nt;J++){const r=R>>1,j=J>>1;
Y.push(R%2===0&&J%2===0?old(r,j):R%2===0?mid(old(r,j),old(r,j+1)):J%2===0?mid(old(r,j),old(r+1,j))
:r<L?[0,1].map(d=>.25*(old(r,j)[d]+old(r+1,j)[d]+old(r+1,j+1)[d]+old(r,j+1)[d])):mid(old(r,j),old(r+1,j+1)));}
X=Y;L*=2;}
const nt=8*L,C=[],id=(r,j)=>r*nt+(j%nt);for(let r=0;r<2*L;r++)for(let j=0;j<nt;j++){const a=id(r,j),b=id(r,j+1),c=id(r+1,j+1),d=id(r+1,j);if(r<L)C.push([a,d,c,b]);else C.push([a,d,c],[a,c,b]);}return {X,C,nt,level};}
function topology(mesh,scheme){const edges=[],map=new Map();mesh.C.forEach((cell,c)=>cell.forEach((a,k)=>{const b=cell[(k+1)%cell.length],key=[Math.min(a,b),Math.max(a,b)].join('-');if(map.has(key))edges[map.get(key)].R=c;else{map.set(key,edges.length);edges.push({a,b,L:c,R:-1});}}));for(const e of edges)e.bc=e.R>=0?'interior':e.a<mesh.nt&&e.b<mesh.nt?'wall':'far';
let faces;if(scheme==='cell')faces=edges.map(e=>({...e,terms:[[e.a,e.b,1]],segs:[[e.a,e.b]]}));
else{const off=mesh.X.length;faces=edges.map(e=>{const a=Math.min(e.a,e.b),b=Math.max(e.a,e.b),mid=off+mesh.C.length+edges.indexOf(e),terms=[],segs=[];for(const c of [e.L,e.R].filter(i=>i>=0)){const cell=mesh.C[c],s=cell[(cell.indexOf(a)+1)%cell.length]===b?1:-1;terms.push([mid,off+c,s]);segs.push([mid,off+c]);}return {a,b,L:a,R:b,bc:'interior',terms,segs};});for(const e of edges.filter(e=>e.R<0)){const mid=off+mesh.C.length+edges.indexOf(e);for(const a of [e.a,e.b])faces.push({L:a,R:-1,bc:e.bc,terms:[[e.a,e.b,.5]],segs:[[a,mid]]});}}
const rank={interior:0,wall:1,far:2};faces.sort((a,b)=>rank[a.bc]-rank[b.bc]||a.L-b.L||a.R-b.R);return {edges,faces};}
function model({scheme='cell',level=1,M=.2,angle=10,objective='lift',coords,mms}={}){
if(!['cell','node'].includes(scheme)||!Number.isInteger(level)||level<1||level>6||!Number.isFinite(M)||M<0||M>.3||!Number.isFinite(angle))throw Error('Unsupported Euler model settings');
const grid=mesh(level);if(coords)grid.X=coords.map(p=>p.slice());const topo=topology(grid,scheme),nc=scheme==='cell'?grid.C.length:grid.X.length,nd=4*nc,params=[M,angle*Math.PI/180,...grid.X.flat()];
// Incoming entropy/vorticity branch is tied to the BASE free-stream normal.
const geo=geometry(real,params.slice(2));topo.faces.forEach((f,i)=>f.inflow=geo.faces[i].n[0]*Math.cos(params[1])+geo.faces[i].n[1]*Math.sin(params[1])<0);
// Manufactured-solution mode (order study only): every boundary face listed in data.exterior uses the far-field
// characteristic closure with that constant exterior state in place of U_inf (its own normal velocity fixes the
// branch; listed wall faces use the same closure), and a constant source is subtracted from the residual.
const data=mms?mms({grid,topo,geo,scheme,nc,nd,physics:physics(real)}):null;
if(data)topo.faces.forEach((f,i)=>{if(f.bc!=='interior'&&data.exterior[i]){f.exterior=data.exterior[i];const z=physics(real).prim(f.exterior);f.inflow=z.u*geo.faces[i].n[0]+z.v*geo.faces[i].n[1]<0;}});
function geometry(o,Xflat){const {c,add,sub,mul,pow}=o,scale=(a,s)=>a.map(v=>mul(v,c(s))),avg=pts=>[0,1].map(k=>mul(pts.reduce((s,p)=>add(s,p[k]),c(0)),c(1/pts.length))),X=Array.from({length:grid.X.length},(_,i)=>Xflat.slice(2*i,2*i+2)),cent=grid.C.map(cell=>avg(cell.map(i=>X[i]))),mid=topo.edges.map(e=>avg([X[e.a],X[e.b]])),points=[...X,...cent,...mid];
const area=poly=>mul(poly.reduce((s,p,i)=>{const q=poly[(i+1)%poly.length];return add(s,sub(mul(p[0],q[1]),mul(p[1],q[0])));},c(0)),c(.5)),vol=Array.from({length:nc},()=>c(0)),polys=Array.from({length:nc},()=>[]);
grid.C.forEach((cell,j)=>{if(scheme==='cell'){vol[j]=area(cell.map(i=>X[i]));polys[j].push(cell.map(i=>X[i]));}else cell.forEach((a,k)=>{const next=cell[(k+1)%cell.length],prev=cell[(k-1+cell.length)%cell.length],poly=[X[a],avg([X[a],X[next]]),cent[j],avg([X[prev],X[a]])];vol[a]=add(vol[a],area(poly));polys[a].push(poly);});});
const faces=topo.faces.map(f=>{let n=[c(0),c(0)];for(const [a,b,s] of f.terms){n[0]=add(n[0],mul(sub(points[b][1],points[a][1]),c(s)));n[1]=sub(n[1],mul(sub(points[b][0],points[a][0]),c(s)));}return {n,len:pow(add(mul(n[0],n[0]),mul(n[1],n[1])),.5),segs:f.segs.map(([a,b])=>[points[a],points[b]])};});return {X,cent,points,vol,polys,faces,locations:scheme==='cell'?cent:X};}
function physics(o){const {c,add,sub,mul,div,pow,sin,cos}=o,scale=(v,s)=>v.map(x=>mul(x,s)),sum=(a,b)=>a.map((x,i)=>add(x,b[i]));
function prim(U){const rho=U[0],u=div(U[1],rho),v=div(U[2],rho),p=mul(c(gamma-1),sub(U[3],mul(c(.5),add(mul(U[1],u),mul(U[2],v))))),a=pow(div(mul(c(gamma),p),rho),.5);return {rho,u,v,p,a};}
function cons(r,u,v,p){return [r,mul(r,u),mul(r,v),add(div(p,c(gamma-1)),mul(mul(c(.5),r),add(mul(u,u),mul(v,v))))];}
function physical(U,n){const q=prim(U),un=add(mul(q.u,n[0]),mul(q.v,n[1]));return [mul(q.rho,un),add(mul(U[1],un),mul(q.p,n[0])),add(mul(U[2],un),mul(q.p,n[1])),mul(add(U[3],q.p),un)];}
function far(UL,UI,n,len,inflow,log){const q=prim(UL),z=prim(UI),nx=div(n[0],len),ny=div(n[1],len),normal=q=>add(mul(q.u,nx),mul(q.v,ny)),tan=q=>sub(mul(q.v,nx),mul(q.u,ny)),rp=add(normal(q),mul(c(2/(gamma-1)),q.a)),rm=sub(normal(z),mul(c(2/(gamma-1)),z.a)),un=mul(c(.5),add(rp,rm)),sound=mul(c((gamma-1)/4),sub(rp,rm)),up=inflow?z:q,S=div(up.p,pow(up.rho,gamma)),rho=pow(div(mul(sound,sound),mul(c(gamma),S)),1/(gamma-1)),ut=tan(up),u=sub(mul(un,nx),mul(ut,ny)),v=add(mul(un,ny),mul(ut,nx)),pb=mul(S,pow(rho,gamma));
if(log)Object.assign(log,{nx,ny,unL:normal(q),unInf:normal(z),utL:tan(q),utInf:tan(z),cL:q.a,cInf:z.a,SL:div(q.p,pow(q.rho,gamma)),SInf:div(z.p,pow(z.rho,gamma)),rp,rm,un,c:sound,S,rho,ut,u,v,p:pb});
return cons(rho,u,v,pb);}
function free(p){return cons(c(1),mul(mul(p[0],c(Math.sqrt(gamma))),cos(p[1])),mul(mul(p[0],c(Math.sqrt(gamma))),sin(p[1])),c(1));}
function flux(UL,UR,f,g,UI){const q=prim(UL);if(f.bc==='wall'&&!f.exterior)return {H:[c(0),mul(q.p,g.n[0]),mul(q.p,g.n[1]),c(0)],UR:UL};const B=f.R>=0?UR:far(UL,f.exterior?f.exterior.map(c):UI,g.n,g.len,f.inflow),FL=physical(UL,g.n),FR=physical(B,g.n);return {UR:B,H:FL.map((x,k)=>sub(mul(c(.5),add(x,FR[k])),mul(mul(c(alpha/2),g.len),sub(B[k],UL[k]))))};}
return {prim,cons,physical,far,free,flux};}
function evaluate(o,U,p){const {c,add,sub,mul}=o,g=geometry(o,p.slice(2)),ph=physics(o),UI=ph.free(p),R=Array.from({length:nd},()=>c(0)),local=[];let J=c(0);
topo.faces.forEach((f,j)=>{const L=U.slice(4*f.L,4*f.L+4),RR=f.R>=0?U.slice(4*f.R,4*f.R+4):null,q=ph.flux(L,RR,f,g.faces[j],UI);q.H.forEach((h,k)=>{R[4*f.L+k]=add(R[4*f.L+k],h);if(f.R>=0)R[4*f.R+k]=sub(R[4*f.R+k],h);});if(f.bc==='wall')J=add(J,mul(ph.prim(L).p,g.faces[j].n[objective==='drag'?0:1]));local.push({...q,UL:L});});if(data)for(let i=0;i<nd;i++)R[i]=sub(R[i],c(data.forcing[i]));return {R,J,g,local};}
function linearize(U,p=params){const t=tape(),u=U.map(t.op.input),pp=p.map(t.op.input),out=evaluate(t.op,u,pp),outids=[...out.R,out.J];return {R:out.R.map(i=>t.val[i]),J:t.val[out.J],size:t.val.length,faceVjp:(j,w)=>{const b=t.reverse(out.local[j].H,w);return {U:b.slice(0,nd),p:b.slice(nd)};},jvp:(v,dp=zeros(p.length))=>{const d=t.forward([...v,...dp]);return {R:out.R.map(i=>d[i]),J:d[out.J],faces:out.local.map(q=>q.H.map(i=>d[i]))};},vjp:(w,bJ=0)=>{const b=t.reverse(outids,[...w,bJ]);return {U:b.slice(0,nd),p:b.slice(nd)};}};}
function initial(p=params){return Array.from({length:nc},()=>physics(real).free(p)).flat();}
function valid(U,p=params){const ph=physics(real);return Array.from({length:nc},(_,i)=>ph.prim(U.slice(i*4,i*4+4))).every(q=>q.rho>.05&&q.p>.05&&Math.hypot(q.u,q.v)/q.a<.85);}
function precondition(g,transpose=false){const diag=zeros(nc),lower=Array.from({length:nc},()=>[]);topo.faces.forEach((f,j)=>{const a=alpha*g.faces[j].len/2;diag[f.L]+=a;if(f.R>=0){diag[f.R]+=a;const l=Math.max(f.L,f.R),r=Math.min(f.L,f.R);lower[l].push([r,-a]);}});return rhs=>{const z=rhs.slice();if(transpose){for(let i=nc-1;i>=0;i--)for(let k=0;k<4;k++){z[4*i+k]/=diag[i];for(const [j,c] of lower[i])z[4*j+k]-=c*z[4*i+k];}return z;}for(let i=0;i<nc;i++)for(let k=0;k<4;k++){for(const [j,c] of lower[i])z[4*i+k]-=c*z[4*j+k];z[4*i+k]/=diag[i];}return z;};}
function diagnostics(U,p=params){const out=evaluate(real,U,p),ph=physics(real);let maxSpeed=0,maxMach=0,minRho=Infinity,minP=Infinity,mismatches=0;
out.local.forEach((q,j)=>[q.UL,q.UR].forEach((u,k)=>{const z=ph.prim(u),g=out.g.faces[j],un=(z.u*g.n[0]+z.v*g.n[1])/g.len;maxSpeed=Math.max(maxSpeed,Math.abs(un)+z.a);maxMach=Math.max(maxMach,Math.hypot(z.u,z.v)/z.a);minRho=Math.min(minRho,z.rho);minP=Math.min(minP,z.p);if(k===1&&(topo.faces[j].bc==='far'||topo.faces[j].exterior)&&(un<0)!==topo.faces[j].inflow)mismatches++;}));return {maxSpeed,maxMach,minRho,minP,mismatches,minVolume:Math.min(...out.g.vol)};}
function solve(p=params,start,opts={}){let U=start?start.slice():initial(p);const history=[],states=[],P=precondition(geometry(real,p.slice(2)));for(let it=0;it<35;it++){const A=linearize(U,p),rn=norm(A.R);history.push(rn);states.push(U.slice());if(rn<3e-12){const check=diagnostics(U,p);if(opts.check!==false&&!(check.maxSpeed<alpha&&check.maxMach<.85&&check.minRho>.05&&check.minP>.05&&check.minVolume>0&&check.mismatches===0))throw Error('State leaves supported smooth subsonic boundary branch');return {U,J:A.J,history,states,converged:true,A,diagnostics:check};}const l=gmres(v=>A.jvp(v).R,A.R.map(x=>-x),opts.precondition?opts.precondition(A,false):P,Math.min(1e-12,rn*1e-3),nd);if(!l.converged&&l.residual>rn*.01)throw Error('Euler Newton linear solve failed: '+l.residual);let step=1,next;while(step>1e-7){next=U.map((x,i)=>x+step*l.x[i]);if(valid(next,p)&&norm(evaluate(real,next,p).R)<(1-1e-4*step)*rn)break;step*=.5;}if(step<=1e-7)throw Error('Euler positivity/residual line search failed');U=next;}throw Error('Euler primal did not converge');}
function sensitivities(sol,p=params,opts={}){const A=sol.A||linearize(sol.U,p),P=opts.precondition?opts.precondition(A,true):precondition(geometry(real,p.slice(2)),true),Jg=A.vjp(zeros(nd),1),adj=gmres(v=>A.vjp(v).U,Jg.U.map(x=>-x),P,2e-12,nd);if(!adj.converged)throw Error('Euler adjoint failed');const total=A.vjp(adj.x,1);return {psi:adj.x,gradient:total.p,history:adj.history,residual:adj.residual};}
function tangent(sol,k,p=params){const A=sol.A||linearize(sol.U,p),dp=zeros(p.length);dp[k]=1;const direct=A.jvp(zeros(nd),dp),P=precondition(geometry(real,p.slice(2))),l=gmres(v=>A.jvp(v).R,direct.R.map(x=>-x),P,2e-12,nd);if(!l.converged)throw Error('Euler tangent failed');return {...l,gradient:A.jvp(l.x,dp).J};}
function complexGradient(sol,k,h=1e-25,p=params){const A=sol.A||linearize(sol.U,p),pp=p.map((x,i)=>[x,i===k?h:0]),U=sol.U.map(x=>[x,0]),P=precondition(geometry(real,p.slice(2)));let im=zeros(nd),history=[];for(let it=0;it<4;it++){const out=evaluate(complex,U,pp),rhs=out.R.map(x=>-x[1]/h),rn=norm(rhs);history.push(rn);if(rn<2e-12)break;const l=gmres(v=>A.jvp(v).R,rhs,P,1e-12,nd);im=im.map((x,i)=>x+l.x[i]);U.forEach((x,i)=>x[1]=h*im[i]);}const out=evaluate(complex,U,pp),residual=norm(out.R.map(x=>x[1]/h));if(residual>2e-10)throw Error('complex residual not converged');return {gradient:out.J[1]/h,residual,history};}
function boundary(j,U,p=params){ // one far face: intermediate values, U_b and G_U=dU_b/dU_L (by the same far())
const f=topo.faces[j];if(f.bc!=='far'&&!f.exterior)throw Error('Not a far-field face');const g=geometry(real,p.slice(2)).faces[j],ph=physics(real),UL=U.slice(4*f.L,4*f.L+4),UI=f.exterior||ph.free(p),log={},Ub=ph.far(UL,UI,g.n,g.len,f.inflow,log),t=tape(),x=UL.map(t.op.input),out=physics(t.op).far(x,UI.map(t.op.c),g.n.map(t.op.c),t.op.c(g.len),f.inflow),cols=[0,1,2,3].map(k=>{const d=t.forward([0,1,2,3].map(i=>+(i===k)));return out.map(o=>d[o]);});
return {face:j,inflow:f.inflow,UL,UI,Ub,log,n:g.n,len:g.len,G:[0,1,2,3].map(r=>cols.map(col=>col[r]))};}
function riemann(U,n,len){ // Riemann coordinates W=(r+,r-,S,u_t) of a state at a face and dW/dU (same prim())
const t=tape(),x=U.map(t.op.input),{c,add,sub,mul,div,pow}=t.op,q=physics(t.op).prim(x),nx=c(n[0]/len),ny=c(n[1]/len),un=add(mul(q.u,nx),mul(q.v,ny)),ut=sub(mul(q.v,nx),mul(q.u,ny)),k=c(2/(gamma-1)),out=[add(un,mul(k,q.a)),sub(un,mul(k,q.a)),div(q.p,pow(q.rho,gamma)),ut],cols=[0,1,2,3].map(j=>{const d=t.forward([0,1,2,3].map(i=>+(i===j)));return out.map(o=>d[o]);});
return {W:out.map(o=>t.val[o]),dW:[0,1,2,3].map(r=>cols.map(col=>col[r]))};}
function fluxJacobian(U,n){const t=tape(),x=U.map(t.op.input),out=physics(t.op).physical(x,n.map(t.op.c)),cols=[0,1,2,3].map(k=>{const d=t.forward([0,1,2,3].map(i=>+(i===k)));return out.map(o=>d[o]);});return [0,1,2,3].map(r=>cols.map(col=>col[r]));}
return {scheme,grid,topo,nc,nd,params,data,boundary,riemann,fluxJacobian,diagnostics,preconditioner:transpose=>precondition(geo,transpose),geometry:()=>geometry(real,params.slice(2)),geometryAt:p=>geometry(real,p.slice(2)),physics:physics(real),evaluate:(U,p=params)=>evaluate(real,U,p),linearize,initial,solve,sensitivities,tangent,complexGradient,valid};
}
function gmres(A,b,P=x=>x.slice(),tol=1e-11,max=b.length){const n=b.length,beta=norm(b);if(beta<=tol)return {x:zeros(n),residual:beta,history:[beta],converged:true};const V=[b.map(x=>x/beta)],Z=[],H=[],cs=zeros(max),sn=zeros(max),g=zeros(max+1),history=[beta];g[0]=beta;let m=0;
for(let j=0;j<max;j++){const h=zeros(j+2);H.push(h);Z.push(P(V[j]));let w=A(Z[j]);for(let pass=0;pass<2;pass++)for(let i=0;i<=j;i++){const hij=dot(V[i],w);h[i]+=hij;w=w.map((x,k)=>x-hij*V[i][k]);}h[j+1]=norm(w);const nextnorm=h[j+1];if(nextnorm>1e-16)V.push(w.map(x=>x/nextnorm));for(let i=0;i<j;i++){const a=h[i],b=h[i+1];h[i]=cs[i]*a+sn[i]*b;h[i+1]=-sn[i]*a+cs[i]*b;}const a=h[j],bb=h[j+1],r=Math.hypot(a,bb);cs[j]=r?a/r:1;sn[j]=r?bb/r:0;h[j]=r;h[j+1]=0;g[j+1]=-sn[j]*g[j];g[j]*=cs[j];m=j+1;history.push(Math.abs(g[j+1]));if(Math.abs(g[j+1])<tol||nextnorm<=1e-16)break;}
const y=zeros(m);for(let i=m-1;i>=0;i--){let a=g[i];for(let j=i+1;j<m;j++)a-=H[j][i]*y[j];y[i]=a/H[i][i];}const x=zeros(n);for(let i=0;i<m;i++)for(let j=0;j<n;j++)x[j]+=Z[i][j]*y[i];const residual=norm(A(x).map((z,i)=>z-b[i]));return {x,residual,history,converged:residual<=Math.max(tol*5,1e-13)};}
const api={model,mesh,gamma,alpha,gmres,dot,norm,real,complex,tape};if(typeof module!=='undefined')module.exports=api;else root.Euler2D=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: geometry.js
(function(root){'use strict';
const E=typeof module!=='undefined'?require('../e2/core_euler'):root.Euler2D;
function mesh(level=0){let {X,C,nt}=E.mesh(1);const body=Array.from({length:nt},(_,i)=>i);for(let k=0;k<level;k++){const edges=new Map(),mid=(a,b)=>{const key=[a,b].sort((x,y)=>x-y).join(',');if(!edges.has(key)){edges.set(key,X.length);X.push(X[a].map((v,d)=>(v+X[b][d])/2));}return edges.get(key);},D=[];for(const cell of C){const mids=cell.map((a,i)=>mid(a,cell[(i+1)%cell.length]));if(cell.length===3){const [a,b,c]=cell,[ab,bc,ca]=mids;D.push([a,ab,ca],[ab,b,bc],[ca,bc,c],[ab,bc,ca]);}else{const q=X.length;X.push([0,1].map(d=>cell.reduce((s,i)=>s+X[i][d],0)/cell.length));cell.forEach((a,i)=>D.push([a,mids[i],q,mids[(i+3)%4]]));}}C=D;}return {X,C,nt,body,level};}
function topology(grid,node){const edges=[],map=new Map();grid.C.forEach((cell,i)=>cell.forEach((a,k)=>{const b=cell[(k+1)%cell.length],key=[a,b].sort((x,y)=>x-y).join(',');if(map.has(key))edges[map.get(key)].R=i;else{map.set(key,edges.length);edges.push({a,b,L:i,R:-1});}}));const isWall=e=>{const a=grid.X[e.a],b=grid.X[e.b];return Math.hypot(...a)<1.1&&Math.hypot(...b)<1.1;};edges.forEach(e=>e.bc=e.R>=0?'interior':isWall(e)?'wall':'far');const off=grid.X.length,moff=off+grid.C.length,faces=[];
edges.forEach((e,j)=>{if(!node)faces.push({...e,segs:[[e.a,e.b,1]]});else{const a=Math.min(e.a,e.b),b=Math.max(e.a,e.b),segs=[];for(const ci of [e.L,e.R].filter(i=>i>=0)){const cell=grid.C[ci],sgn=cell[(cell.indexOf(a)+1)%cell.length]===b?1:-1;segs.push([moff+j,off+ci,sgn]);}faces.push({L:a,R:b,bc:'interior',segs});if(e.R<0){faces.push({L:e.a,R:-1,bc:e.bc,segs:[[e.a,moff+j,1]]},{L:e.b,R:-1,bc:e.bc,segs:[[moff+j,e.b,1]]});}}});faces.sort((a,b)=>({interior:0,wall:1,far:2}[a.bc]-{interior:0,wall:1,far:2}[b.bc])||a.L-b.L||a.R-b.R);
const nc=node?grid.X.length:grid.C.length,neighbors=Array.from({length:nc},()=>new Set());faces.forEach(f=>{if(f.R>=0){neighbors[f.L].add(f.R);neighbors[f.R].add(f.L);}});const stencil=neighbors.map((_,i)=>{let seen=new Set([i]),front=[i];for(let k=0;k<5&&seen.size<Math.min(12,nc);k++){const next=[];for(const a of front)for(const b of neighbors[a])if(!seen.has(b)){seen.add(b);next.push(b);}front=next;}return [...seen].filter(j=>j!==i).sort((a,b)=>a-b);});return {edges,faces,neighbors:neighbors.map(s=>[...s]),stencil};}
function geometry(o,grid,top,node,flat){const {c,add:A,sub:S,mul:M,div:D,pow}=o,sum=a=>a.reduce(A,c(0)),vadd=(a,b)=>a.map((x,i)=>A(x,b[i])),scale=(a,s)=>a.map(x=>M(x,s)),cross=(a,b)=>S(M(a[0],b[1]),M(a[1],b[0])),diff=(a,b)=>a.map((x,i)=>S(x,b[i])),avg=p=>[0,1].map(k=>D(sum(p.map(x=>x[k])),c(p.length))),X=grid.X.map((_,i)=>flat.slice(2*i,2*i+2)),cent0=grid.C.map(cell=>avg(cell.map(i=>X[i]))),mid=top.edges.map(e=>avg([X[e.a],X[e.b]])),points=[...X,...cent0,...mid],polys=Array.from({length:node?X.length:grid.C.length},()=>[]);
grid.C.forEach((cell,i)=>{if(!node)polys[i].push(cell.map(j=>X[j]));else cell.forEach((a,k)=>polys[a].push([X[a],avg([X[a],X[cell[(k+1)%cell.length]]]),cent0[i],avg([X[a],X[cell[(k+cell.length-1)%cell.length]]])]))});
const quadrature=[],vol=[],cent=[],mom=[];const bary=[[1/3,1/3,1/3,.225],[.05971587178977,.470142064105115,.470142064105115,.132394152788506],[.470142064105115,.05971587178977,.470142064105115,.132394152788506],[.470142064105115,.470142064105115,.05971587178977,.132394152788506],[.797426985353087,.101286507323456,.101286507323456,.125939180544827],[.101286507323456,.797426985353087,.101286507323456,.125939180544827],[.101286507323456,.101286507323456,.797426985353087,.125939180544827]];
polys.forEach((ps,i)=>{const qs=[];for(const poly of ps)for(let k=1;k<poly.length-1;k++){const a=poly[0],b=poly[k],d=poly[k+1],ar=M(c(.5),cross(diff(b,a),diff(d,a)));for(const [u,v,w,ww]of bary)qs.push({x:[0,1].map(j=>sum([M(c(u),a[j]),M(c(v),b[j]),M(c(w),d[j])])),w:M(c(ww),ar)});}const V=sum(qs.map(q=>q.w)),cc=[0,1].map(k=>D(sum(qs.map(q=>M(q.w,q.x[k]))),V));quadrature.push(qs);vol.push(V);cent.push(node?X[i]:cc);mom.push(node?[c(0),c(0),c(0)]:[D(sum(qs.map(q=>M(q.w,pow(S(q.x[0],cc[0]),2)))),V),D(sum(qs.map(q=>M(q.w,M(S(q.x[0],cc[0]),S(q.x[1],cc[1]))))),V),D(sum(qs.map(q=>M(q.w,pow(S(q.x[1],cc[1]),2)))),V)]);});
const faces=top.faces.map(f=>{const segs=f.segs.map(([i,j,sg])=>{const a=points[i],b=points[j],d=diff(b,a),nv=[M(c(sg),d[1]),M(c(-sg),d[0])],len=pow(sum(nv.map(x=>M(x,x))),.5),n=nv.map(x=>D(x,len)),samples=[-1/Math.sqrt(3),1/Math.sqrt(3)].map(z=>({x:avg([a,b]).map((v,k)=>A(v,M(c(z/2),d[k]))),w:M(c(.5),len)}));return {a,b,nv,n,len,samples};});return {segs,n:segs.reduce((s,g)=>vadd(s,g.nv),[c(0),c(0)]),len:sum(segs.map(s=>s.len))};});
return {X,cent,locations:cent,mom,vol,polys,faces,quadrature};}
function reconstruct(o,g,top,node){const {c,add:A,sub:S,mul:M,div:D}=o,sum=a=>a.reduce(A,c(0)),basis=(i,j)=>{const dx=S(g.cent[j][0],g.cent[i][0]),dy=S(g.cent[j][1],g.cent[i][1]);return [dx,dy,S(A(M(dx,dx),g.mom[j][0]),g.mom[i][0]),S(A(M(dx,dy),g.mom[j][1]),g.mom[i][1]),S(A(M(dy,dy),g.mom[j][2]),g.mom[i][2])];};
return top.stencil.map((nb,i)=>{const bs=nb.map(j=>basis(i,j)),weights=bs.map(b=>D(c(1),A(M(b[0],b[0]),M(b[1],b[1])))),H=Array.from({length:5},(_,a)=>Array.from({length:5},(_,b)=>sum(bs.map((r,k)=>M(weights[k],M(r[a],r[b])))))),inv=Array.from({length:5},(_,i)=>Array.from({length:5},(_,j)=>c(i===j?1:0)));
// SPD Gram matrix; no pivot branch. Finite weights and polynomial reproduction are checked on real geometry before use.
for(let k=0;k<5;k++){const p=H[k][k];for(let j=0;j<5;j++){H[k][j]=D(H[k][j],p);inv[k][j]=D(inv[k][j],p);}for(let i=0;i<5;i++)if(i!==k){const a=H[i][k];for(let j=0;j<5;j++){H[i][j]=S(H[i][j],M(a,H[k][j]));inv[i][j]=S(inv[i][j],M(a,inv[k][j]));}}}
return bs.map((b,k)=>Array.from({length:5},(_,a)=>M(weights[k],sum(b.map((x,j)=>M(inv[a][j],x))))));});}
const api={mesh,topology,geometry,reconstruct};if(typeof module!=='undefined')module.exports=api;else root.NS2Geometry=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: physics.js
(function(root){'use strict';
function physics(o,variable){const {c,add:A,sub:S,mul:M,div:D,pow}=o;const cons=(r,u,v,T)=>[r,M(r,u),M(r,v),M(r,A(M(c(2.5),T),M(c(.5),A(M(u,u),M(v,v)))))];
const prim=U=>{const rho=U[0],u=D(U[1],rho),v=D(U[2],rho),T=D(S(D(U[3],rho),M(c(.5),A(M(u,u),M(v,v)))),c(2.5));return {rho,u,v,T,p:M(rho,T)};};
const gradient=(U,G)=>{const q=prim(U),gu=G.map(g=>D(S(g[1],M(q.u,g[0])),q.rho)),gv=G.map(g=>D(S(g[2],M(q.v,g[0])),q.rho)),gt=G.map((g,k)=>D(S(D(S(g[3],M(D(U[3],q.rho),g[0])),q.rho),A(M(q.u,gu[k]),M(q.v,gv[k]))),c(2.5)));return {u:gu,v:gv,T:gt};};
const transport=(T,p)=>{const mu=variable?M(p[2],D(M(c(1.4),pow(T,1.5)),A(T,c(.4)))):p[2];return {mu,kappa:D(M(c(3.5),mu),p[3])};};
const viscous=(q,G,n,p)=>{const {mu,kappa}=transport(q.T,p),div=A(G.u[0],G.v[1]),txx=M(mu,S(M(c(2),G.u[0]),M(c(2/3),div))),tyy=M(mu,S(M(c(2),G.v[1]),M(c(2/3),div))),txy=M(mu,A(G.u[1],G.v[0])),tn=[A(M(txx,n[0]),M(txy,n[1])),A(M(txy,n[0]),M(tyy,n[1]))],qn=M(c(-1),M(kappa,A(M(G.T[0],n[0]),M(G.T[1],n[1])))),F=[c(0),...tn,S(A(M(q.u,tn[0]),M(q.v,tn[1])),qn)];return {mu,kappa,txx,tyy,txy,tn,qn,F};};
const flux=(U,n)=>{const q=prim(U),un=A(M(q.u,n[0]),M(q.v,n[1]));return [M(q.rho,un),A(M(U[1],un),M(q.p,n[0])),A(M(U[2],un),M(q.p,n[1])),M(A(U[3],q.p),un)];};return {prim,cons,gradient,transport,viscous,flux};}
// Spatial first jets nested over real / complex / parameter AD arithmetic.
function jets(o){const {c,add:A,sub:S,mul:M,div:D,pow}=o,z=()=>c(0),constant=x=>[x,z(),z()],add=(a,b)=>a.map((x,i)=>A(x,b[i])),sub=(a,b)=>a.map((x,i)=>S(x,b[i])),mul=(a,b)=>[M(a[0],b[0]),A(M(a[1],b[0]),M(a[0],b[1])),A(M(a[2],b[0]),M(a[0],b[2]))],power=(a,p)=>[pow(a[0],p),M(M(c(p),pow(a[0],p-1)),a[1]),M(M(c(p),pow(a[0],p-1)),a[2])];return {c:x=>constant(c(x)),add,sub,mul,div:(a,b)=>mul(a,power(b,-1)),pow:power,sin:a=>[o.sin(a[0]),M(o.cos(a[0]),a[1]),M(o.cos(a[0]),a[2])],cos:a=>[o.cos(a[0]),M(M(c(-1),o.sin(a[0])),a[1]),M(M(c(-1),o.sin(a[0])),a[2])],constant};}
function exact(o,x){const {c,add:A,mul:M,sin,cos}=o;return physics(o,false).cons(A(c(1),M(c(.025),sin(A(M(c(.7),x[0]),M(c(.4),x[1]))))),A(c(.22),M(c(.025),sin(A(M(c(.5),x[0]),M(c(.6),x[1]))))),A(c(.055),M(c(.01),cos(A(M(c(.8),x[0]),M(c(-.3),x[1]))))),A(c(1),M(c(.04),sin(A(M(c(.6),x[0]),M(c(.7),x[1]))))));}
function exactData(o,x,p,variable,field=exact){const j=jets(o),xx=[[x[0],o.c(1),o.c(0)],[x[1],o.c(0),o.c(1)]],U=field(j,xx),val=U.map(v=>v[0]),G=[1,2].map(k=>U.map(v=>v[k])),ph=physics(o,variable),q=ph.prim(val);return {U:val,q,G:ph.gradient(val,G)};}
function source(o,x,base,variable,field=exact){const j=jets(o),xx=[[x[0],o.c(1),o.c(0)],[x[1],o.c(0),o.c(1)]],p=base.map(v=>j.c(v)),d=exactData(j,xx,p,variable,field),ph=physics(j,variable);let R=Array.from({length:4},()=>o.c(0));for(let k=0;k<2;k++){const n=[j.c(k===0?1:0),j.c(k===1?1:0)],fc=ph.flux(d.U,n),fv=ph.viscous(d.q,d.G,n,p).F;R=R.map((v,i)=>o.add(v,o.sub(fc[i][k+1],fv[i][k+1])));}return R;}
const api={physics,exact,exactData,source,jets};if(typeof module!=='undefined')module.exports=api;else root.NS2Physics=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: wall_mms.js
(function(root){'use strict';
// Wall-compatible manufactured field for the no-slip MMS study (Section 15).
// On every edge of the fixed inner polygon it gives u=v=0 and T=1 exactly;
// the adiabatic variant also has grad T=0 there, hence zero normal heat flux.
// Only c, add, sub, mul, div, pow, sin and cos are used, so the same function
// runs on reals, complex numbers, AD tapes and the nested spatial jets that
// manufacture the source. The positive part z+ = (z+|z|)/2 with
// |z| = (z*z)^(1/2) makes the far-field blend exactly 0 near the body and
// exactly 1 near the outer boundary; the quartic-contact smoothstep keeps
// the blend three times continuously differentiable.
const G=typeof module!=='undefined'?require('./geometry'):root.NS2Geometry,P=typeof module!=='undefined'?require('./physics'):root.NS2Physics;
const R0=.85,R1=1.75,EPS=.5,AMP=2;
let lines=null;
// Outward unit normals of the eight inner edges (fluid side has l>0).
function polygon(){if(!lines){const X=G.mesh(0).X.slice(0,8);lines=X.map((a,i)=>{const b=X[(i+1)%8],dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy);return {a,b,n:[dy/L,-dx/L],length:L};});}return lines;}
function positive(o,z){return o.mul(o.c(.5),o.add(z,o.pow(o.mul(z,z),.5)));}
// C3 blend: 0 for r<=R0, 1 for r>=R1.
function blend(o,x){const {c,add:A,sub:S,mul:M,div:D}=o,r2=A(M(x[0],x[0]),M(x[1],x[1])),z=D(S(r2,c(R0*R0)),c(R1*R1-R0*R0)),t=S(positive(o,z),positive(o,S(z,c(1)))),t2=M(t,t);return M(M(t2,t2),A(c(35),M(t,A(c(-84),M(t,A(c(70),M(t,c(-20))))))));}
// Product of bounded edge-line factors l/sqrt(EPS^2+l^2): zero exactly on
// every inner edge line, smooth everywhere, nonzero normal slope on edges.
function wallFactor(o,x){const {c,add:A,sub:S,mul:M,pow}=o;let phi=c(-AMP);for(const e of polygon()){const l=A(M(c(e.n[0]),S(x[0],c(e.a[0]))),M(c(e.n[1]),S(x[1],c(e.a[1]))));phi=M(phi,M(l,pow(A(c(EPS*EPS),M(l,l)),-.5)));}return phi;}
function exact(o,x,thermal='isothermal'){const {c,add:A,sub:S,mul:M,sin,cos}=o,s=blend(o,x),psi=wallFactor(o,x),one=c(1),rho=A(one,M(c(.025),sin(A(M(c(.7),x[0]),M(c(.4),x[1]))))),uf=A(c(.22),M(c(.025),sin(A(M(c(.5),x[0]),M(c(.6),x[1]))))),vf=A(c(.055),M(c(.01),cos(A(M(c(.8),x[0]),M(c(-.3),x[1]))))),Tf=M(c(.04),sin(A(M(c(.6),x[0]),M(c(.7),x[1])))),w=A(s,M(S(one,s),psi)),theta=M(c(.05),A(one,M(c(.5),sin(A(M(c(.9),x[0]),M(c(-.4),x[1])))))),near=thermal==='adiabatic'?M(M(psi,psi),theta):M(psi,theta),T=A(one,A(M(s,Tf),M(S(one,s),near)));return P.physics(o,false).cons(rho,M(w,uf),M(w,vf),T);}
const api={exact,polygon,blend,wallFactor,constants:{R0,R1,EPS,AMP}};if(typeof module!=='undefined')module.exports=api;else root.NS2WallMMS=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: engineering_pc.js
/* Serial teaching implementation of the OVERU hierarchy, not a PETSc binding.
* Exact A/A^T remains outside this module. Build this PC from the approximate
* first-order matrix, transposing that matrix BEFORE factorization for adjoints.
*/
(function(root){'use strict';
const defaults={parts:4,overlap:1,fill:1,ordering:'natural',globalIts:1,localIts:1,omega:1,shift:0};
function rows(input){
if(Array.isArray(input))return input.map((r,i)=>{const out=r instanceof Map?new Map(r):new Map(r.map((v,j)=>[j,v]).filter(([j,v])=>v!==0||j===i));if(!out.has(i))out.set(i,0);return out;});
const out=Array.from({length:input.n},()=>new Map());for(let i=0;i<input.n;i++)for(let k=input.ptr[i];k<input.ptr[i+1];k++)out[i].set(input.col[k],input.val[k]);return out;
}
function transpose(A){const B=A.map(()=>new Map());A.forEach((r,i)=>r.forEach((v,j)=>B[j].set(i,v)));return B;}
function mv(A,x){return A.map(r=>{let s=0;for(const [j,v]of r)s+=v*x[j];return s;});}
function adjacency(A,block=4){const n=A.length/block,g=Array.from({length:n},()=>new Set());A.forEach((r,i)=>r.forEach((v,j)=>{const a=Math.floor(i/block),b=Math.floor(j/block);if(v!==0&&a!==b){g[a].add(b);g[b].add(a);}}));return g;}
function bfs(g,reverse=false){const seen=new Set(),out=[];while(seen.size<g.length){let start=-1;for(let i=0;i<g.length;i++)if(!seen.has(i)&&(start<0||g[i].size<g[start].size))start=i;const q=[start];seen.add(start);for(let k=0;k<q.length;k++){const i=q[k];out.push(i);for(const j of [...g[i]].sort((a,b)=>g[a].size-g[b].size||a-b))if(!seen.has(j)){seen.add(j);q.push(j);}}}return reverse?out.reverse():out;}
// ILU(k): symbolic fill levels, then numeric elimination on exactly that graph.
function ilu(A,fill){
const levels=A.map((r,i)=>new Map([...r.keys(),i].map(j=>[j,0]))),factors=[],scale=A.map(r=>Math.max(1,...[...r.values()].map(Math.abs)));let shiftedPivots=0,nnz=0;
for(let i=0;i<A.length;i++){
const lev=levels[i],lower=[...lev.keys()].filter(j=>j<i).sort((a,b)=>a-b);for(let at=0;at<lower.length;at++){const j=lower[at],lij=lev.get(j);for(const [k,ljk]of levels[j])if(k>j){const level=lij+ljk+1;if(level<=fill&&level<(lev.get(k)??Infinity)){if(!lev.has(k)&&k<i){let q=at+1;while(q<lower.length&&lower[q]<k)q++;lower.splice(q,0,k);}lev.set(k,level);}}}
const f=new Map([...lev.keys()].sort((a,b)=>a-b).map(j=>[j,A[i].get(j)||0]));
for(const [j,a]of f){if(j>=i)break;const multiplier=a/factors[j].get(j);f.set(j,multiplier);for(const [k,b]of factors[j])if(k>j&&f.has(k))f.set(k,f.get(k)-multiplier*b);}
const floor=1e-12*scale[i],pivot=f.get(i);if(!Number.isFinite(pivot))throw Error('ILU non-finite pivot at '+i);if(Math.abs(pivot)<floor){f.set(i,pivot<0?-floor:floor);shiftedPivots++;}factors.push(f);nnz+=f.size;
}
const apply=b=>{const x=Array.from(b);for(let i=0;i<A.length;i++)for(const [j,a]of factors[i]){if(j>=i)break;x[i]-=a*x[j];}for(let i=A.length-1;i>=0;i--){for(const [j,a]of factors[i])if(j>i)x[i]-=a*x[j];x[i]/=factors[i].get(i);}return x;};
return {apply,nnz,shiftedPivots,factors};
}
function build(input,options={},weights){
const opt={...defaults,...options},A=rows(input),n=A.length;
for(const [k,lo,hi]of [['parts',1,32],['overlap',0,3],['fill',0,3],['globalIts',1,5],['localIts',1,5]])if(!Number.isInteger(opt[k])||opt[k]<lo||opt[k]>hi)throw Error('Invalid '+k);
if(!['natural','rcm'].includes(opt.ordering)||!Number.isFinite(opt.shift)||opt.shift<0||!(opt.omega>0&&opt.omega<=1)||n%4!==0||!n)throw Error('Invalid engineering PC controls');
if(weights&&weights.length!==n)throw Error('Invalid shift weights');
if(opt.shift)A.forEach((r,i)=>r.set(i,(r.get(i)||0)+opt.shift*(weights?weights[i]:1)));
for(const r of A)for(const [j,v]of r)if(j<0||j>=n||!Number.isFinite(v))throw Error('Invalid approximate matrix');
const graph=adjacency(A),order=bfs(graph),parts=Math.min(opt.parts,graph.length),subdomains=[];
for(let s=0;s<parts;s++){
const owned=order.slice(Math.floor(s*order.length/parts),Math.floor((s+1)*order.length/parts)),ownedSet=new Set(owned),over=new Set(owned);
for(let k=0;k<opt.overlap;k++)for(const i of [...over])for(const j of graph[i])over.add(j);
let nodes=[...over].sort((a,b)=>a-b);
if(opt.ordering==='rcm'){const inv=new Map(nodes.map((v,i)=>[v,i])),g=nodes.map(i=>new Set([...graph[i]].filter(j=>inv.has(j)).map(j=>inv.get(j))));nodes=bfs(g,true).map(i=>nodes[i]);}
const ids=nodes.flatMap(i=>[4*i,4*i+1,4*i+2,4*i+3]),inv=new Map(ids.map((v,i)=>[v,i])),local=ids.map(i=>new Map([...A[i]].filter(([j])=>inv.has(j)).map(([j,v])=>[inv.get(j),v]))),factor=ilu(local,opt.fill),keep=ids.map(i=>ownedSet.has(Math.floor(i/4)));
subdomains.push({owned,nodes,ids,local,factor,keep});
}
// Restricted ASM: read overlap; scatter only the owned correction. This is
// PETSc's default restricted ASM, as used by OVERU's hierarchical branch.
function schwarz(b){const z=Array(n).fill(0);for(const s of subdomains){const rhs=s.ids.map(i=>b[i]);let x=rhs.map(()=>0);for(let k=0;k<opt.localIts;k++){const ax=mv(s.local,x),d=s.factor.apply(rhs.map((v,i)=>v-ax[i]));x=x.map((v,i)=>v+d[i]);}s.ids.forEach((i,j)=>{if(s.keep[j])z[i]=x[j];});}return z;}
function apply(b){if(b.length!==n||!b.every(Number.isFinite))throw Error('Invalid PC right hand side');let x=Array(n).fill(0);for(let k=0;k<opt.globalIts;k++){const ax=mv(A,x),d=schwarz(b.map((v,i)=>v-ax[i]));x=x.map((v,i)=>v+opt.omega*d[i]);}if(!x.every(Number.isFinite))throw Error('Non-finite PC output');return x;}
return {apply,A,subdomains,options:opt,stats:{parts,overlap:opt.overlap,fill:opt.fill,ordering:opt.ordering,globalIts:opt.globalIts,localIts:opt.localIts,shift:opt.shift,nnz:subdomains.reduce((s,d)=>s+d.factor.nnz,0),shiftedPivots:subdomains.reduce((s,d)=>s+d.factor.shiftedPivots,0)}};
}
const api={defaults,rows,transpose,mv,ilu,build};if(typeof module!=='undefined')module.exports=api;else root.NS2EngineeringPC=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: core.js
(function(root){'use strict';
const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath,N=typeof module!=='undefined'?require('../experiments/numerics'):root.EduNumerics,G=typeof module!=='undefined'?require('./geometry'):root.NS2Geometry,P=typeof module!=='undefined'?require('./physics'):root.NS2Physics,E=typeof module!=='undefined'?require('./engineering_pc'):root.NS2EngineeringPC,WM=()=>typeof module!=='undefined'?require('./wall_mms'):root.NS2WallMMS;
const budget=()=>{if(root.NS2Budget)root.NS2Budget.check();};
const complex={...Q.complex,pow:(a,p)=>{if(!Number.isInteger(p))return Q.complex.pow(a,p);let r=[1,0];for(let k=0;k<Math.abs(p);k++)r=Q.complex.mul(r,a);return p<0?Q.complex.div([1,0],r):r;}};
const {norm,dot}=Q,{zeros,factor,solve:lu,transpose,assemble}=N;
function model({node=false,level=0,order=2,variable=false,mode='mms',objective='tracking',thermal='isothermal',mu=.15,Pr=.72,M=.12,angle=.15,coords}={}){
const grid=G.mesh(level);if(coords)grid.X=coords.map(x=>x.slice());const top=G.topology(grid,node),nc=node?grid.X.length:grid.C.length,nd=4*nc,base=[M,angle,mu,Pr,mode==='flow'?1.05:1,0],params=[...base,...grid.X.flat()],np=params.length,geometry=p=>G.geometry(Q.real,grid,top,node,p.slice(6));let cachedKey='',cached;
if(!['mms','flow','wallmms'].includes(mode))throw Error('Unknown case '+mode);
// Manufactured cases take analytic sources and far-boundary data; wall cases
// close the inner boundary as a stationary no-slip wall. 'wallmms' combines
// both: the wall closure verified against a wall-compatible exact field.
const manufactured=mode!=='flow',wallBody=mode!=='mms',field=mode==='wallmms'?(o,x)=>WM().exact(o,x,thermal):P.exact;
const gg=geometry(params);top.faces.forEach((f,k)=>{f.inflow=gg.faces[k].n[0]*.22+gg.faces[k].n[1]*.055<0;});
// Fixed face partition: retain energy balance at an inlet/outlet junction.
// Its inlet and outlet half-faces still impose their own physical data.
const bnodes=Array.from({length:nc},()=>({wall:false,inflow:false,outflow:false}));if(node)top.faces.forEach(f=>{if(f.R<0){if(wallBody&&f.bc==='wall')bnodes[f.L].wall=true;else if(f.inflow)bnodes[f.L].inflow=true;else bnodes[f.L].outflow=true;}});
function prepare(p){if(!p.every(Number.isFinite)||p[2]<=0||p[3]<=0||p[4]<=0)throw Error('Invalid physical parameters');const key=p.slice(6).join(',');if(key===cachedKey)return cached;const g=geometry(p);if(g.vol.some(v=>!Number.isFinite(v)||v<=1e-8)||g.quadrature.some(qs=>qs.some(q=>!(q.w>0)))||g.faces.some(f=>f.segs.some(s=>!(s.len>1e-10))))throw Error('Degenerate or inverted geometry');const W=G.reconstruct(Q.real,g,top,node);if(W.flat(2).some(x=>!Number.isFinite(x)||Math.abs(x)>1e10))throw Error('Invalid quadratic reconstruction');
let reproductionError=0;for(let i=0;i<nc;i++)for(let a=0;a<5;a++)for(let b=0;b<5;b++){const z=top.stencil[i].reduce((sum,j,l)=>{const x=g.cent[j][0]-g.cent[i][0],y=g.cent[j][1]-g.cent[i][1],basis=[x,y,x*x+g.mom[j][0]-g.mom[i][0],x*y+g.mom[j][1]-g.mom[i][1],y*y+g.mom[j][2]-g.mom[i][2]];return sum+W[i][l][a]*basis[b];},0);reproductionError=Math.max(reproductionError,Math.abs(z-(a===b?1:0)));}if(reproductionError>1e-8)throw Error('Quadratic reconstruction lost polynomial reproduction');
const sources=g.quadrature.map(qs=>qs.reduce((s,q)=>{const z=manufactured?P.source(Q.real,q.x,base,variable,field):zeros(4);return s.map((v,k)=>v+q.w*z[k]);},zeros(4)));cachedKey=key;return cached={g,W,sources,reproductionError};}
prepare(params);
function evaluate(o,U,p,space=order,frozen=false){budget();const {c,add:A,sub:S,mul:B,div:D,pow,sin,cos}=o,sum=a=>a.reduce(A,c(0)),dot=(a,b)=>sum(a.map((x,i)=>B(x,b[i]))),scale=(a,b)=>a.map(x=>B(x,b)),avg=(a,b)=>a.map((x,i)=>B(c(.5),A(x,b[i]))),sub=(a,b)=>a.map((x,i)=>S(x,b[i])),ph=P.physics(o,variable),constify=v=>Array.isArray(v)?v.map(constify):v&&typeof v==='object'?Object.fromEntries(Object.entries(v).map(([k,x])=>[k,constify(x)])):c(v);
const cache=frozen?prepare(frozen):null,g=cache?constify(cache.g):G.geometry(o,grid,top,node,p.slice(6)),W=cache?constify(cache.W):G.reconstruct(o,g,top,node),u=Array.from({length:nc},(_,i)=>U.slice(4*i,4*i+4));
const coeff=u.map((v,i)=>Array.from({length:4},(_,k)=>Array.from({length:5},(_,r)=>sum(top.stencil[i].map((j,l)=>B(W[i][l][r],S(u[j][k],v[k])))))));
function rec(i,x,degree=2){const d=sub(x,g.cent[i]),b=[d[0],d[1],S(B(d[0],d[0]),g.mom[i][0]),S(B(d[0],d[1]),g.mom[i][1]),S(B(d[1],d[1]),g.mom[i][2])],v=u[i].map((v,k)=>degree?A(v,sum(b.slice(0,degree===1?2:5).map((x,j)=>B(x,coeff[i][k][j])))):v),grad=[0,1].map(k=>coeff[i].map(a=>k===0?A(a[0],A(B(c(2),B(a[2],d[0])),B(a[3],d[1]))):A(a[1],A(B(a[3],d[0]),B(c(2),B(a[4],d[1]))))));return {U:v,G:grad};}
function prescribed(x,inner=false){if(manufactured){const q=ph.prim(field(o,x));return ph.cons(q.rho,A(q.u,B(c(Math.sqrt(1.4)),S(p[0],c(base[0])))),A(q.v,B(c(.2),S(p[1],c(base[1])))),A(q.T,inner?S(p[4],c(base[4])):c(0)));}return ph.cons(c(1),B(B(p[0],c(Math.sqrt(1.4))),cos(p[1])),B(B(p[0],c(Math.sqrt(1.4))),sin(p[1])),c(1));}
let R=Array.from({length:nd},()=>c(0)),local=[],J=c(0),sources=[];const force=[c(0),c(0)],pressureForce=[c(0),c(0)],viscousForce=[c(0),c(0)];let heat=c(0);
top.faces.forEach((f,fi)=>{let H=Array.from({length:4},()=>c(0)),Fcp=H.slice(),Fvp=H.slice(),samples=[],obj=c(0);for(const seg of g.faces[fi].segs)for(const qp of seg.samples){const x=qp.x,n=seg.n,wl=qp.w,L=rec(f.L,x),a=ph.prim(L.U);let b=a,LR=f.R>=0?rec(f.R,x):null,GG=ph.gradient(L.U,L.G),UL=space===1?u[f.L]:rec(f.L,x,1).U,UR=UL;
let qb,fc,fv,vis;
if(f.R>=0){b=ph.prim(LR.U);qb=ph.prim(avg(L.U,LR.U));const gr=ph.gradient(LR.U,LR.G),d=sub(g.cent[f.R],g.cent[f.L]),dd=dot(d,d);GG=Object.fromEntries(['u','v','T'].map(k=>[k,avg(GG[k],gr[k]).map((v,j)=>A(v,B(D(B(c(2),S(b[k],a[k])),dd),d[j])))]));UR=space===1?u[f.R]:rec(f.R,x,1).U;const fl=ph.flux(UL,n),fr=ph.flux(UR,n);fc=fl.map((v,k)=>S(B(c(.5),A(v,fr[k])),B(c(1.5),S(UR[k],UL[k]))));vis=ph.viscous(qb,GG,n,p);fv=vis.F;}
else if(wallBody&&f.bc==='wall'){qb={...a,u:c(0),v:c(0),T:thermal==='adiabatic'?a.T:p[4]};qb.p=B(qb.rho,qb.T);const dist=node?pow(g.vol[f.L],.5):dot(sub(x,g.cent[f.L]),n);for(const k of ['u','v','T'])if(k!=='T'||thermal!=='adiabatic')GG[k]=GG[k].map((v,j)=>A(v,B(D(B(c(2),S(qb[k],a[k])),dist),n[j])));vis=ph.viscous(qb,GG,n,p);if(thermal==='adiabatic'){vis.qn=c(0);vis.F=[c(0),...vis.tn,c(0)];}fc=[c(0),B(qb.p,n[0]),B(qb.p,n[1]),c(0)];fv=vis.F;UR=ph.cons(qb.rho,qb.u,qb.v,qb.T);}
else if(f.inflow){UR=prescribed(x,f.bc==='wall');qb=ph.prim(UR);const dist=node?pow(g.vol[f.L],.5):dot(sub(x,g.cent[f.L]),n);for(const k of ['u','v','T'])GG[k]=GG[k].map((v,j)=>A(v,B(D(B(c(2),S(qb[k],a[k])),dist),n[j])));fc=ph.flux(UR,n);vis=ph.viscous(qb,GG,n,p);fv=vis.F;}
else{qb=a;const Uout=space===1?u[f.L]:rec(f.L,x,1).U,qout=ph.prim(Uout);qb=qout;let traction=n.map(v=>B(c(1),v)),qn=c(0);if(manufactured){const ex=P.exactData(o,x,p,variable,field),vv=ph.viscous(ex.q,ex.G,n,p);traction=n.map((v,k)=>S(B(ex.q.p,v),vv.tn[k]));qn=vv.qn;}const un=A(B(qout.u,n[0]),B(qout.v,n[1]));fc=[B(qout.rho,un),A(B(Uout[1],un),traction[0]),A(B(Uout[2],un),traction[1]),A(B(Uout[3],un),A(A(B(qout.u,traction[0]),B(qout.v,traction[1])),qn))];fv=Array.from({length:4},()=>c(0));vis={...ph.viscous(qb,GG,n,p),tn:n.map((v,k)=>S(B(qout.p,v),traction[k])),qn};UR=Uout;}
const hh=fc.map((v,k)=>S(v,fv[k]));H=H.map((v,k)=>A(v,B(wl,hh[k])));Fcp=Fcp.map((v,k)=>A(v,B(wl,fc[k])));Fvp=Fvp.map((v,k)=>A(v,B(wl,fv[k])));
if(f.bc==='wall'){const tr=n.map((v,k)=>S(B(qb.p,v),vis.tn[k]));for(let k=0;k<2;k++){force[k]=A(force[k],B(wl,tr[k]));pressureForce[k]=A(pressureForce[k],B(wl,B(qb.p,n[k])));viscousForce[k]=S(viscousForce[k],B(wl,vis.tn[k]));}heat=A(heat,B(wl,vis.qn));if(objective==='forcex'||objective==='forcey')obj=A(obj,B(wl,tr[objective==='forcex'?0:1]));if(objective==='heatflux')obj=A(obj,B(wl,vis.qn));}
samples.push({x,n,w:wl,UL,UR,viscU:L.U,viscUR:LR?LR.U:L.U,Gu:GG.u,Gv:GG.v,GT:GG.T,mu:vis.mu,kappa:vis.kappa,tau:[vis.txx,vis.txy,vis.tyy],tn:vis.tn,qn:vis.qn,fc,fv,H:hh});}
H.forEach((v,k)=>{R[4*f.L+k]=A(R[4*f.L+k],v);if(f.R>=0)R[4*f.R+k]=S(R[4*f.R+k],v);});J=A(J,obj);local.push({H,Fc:Fcp,Fv:Fvp,J:obj,samples});});
for(let i=0;i<nc;i++){let src=cache?cache.sources[i].map(c):Array.from({length:4},()=>c(0));for(const qp of g.quadrature[i]){const x=qp.x,w=qp.w;if(!cache&&manufactured){const ss=P.source(o,x,base,variable,field);src=src.map((v,k)=>A(v,B(w,ss[k])));}const shape=pow(sin(A(B(c(.7),x[0]),B(c(.5),x[1]))),2);src[3]=A(src[3],B(w,B(p[5],shape)));if(objective==='tracking'){const T=ph.prim(space===1?u[i]:rec(i,x,1).U).T,target=A(c(1),B(c(.02),cos(x[0]))),d=S(T,target);J=A(J,B(B(c(.5),w),B(d,d)));}}src.forEach((v,k)=>R[4*i+k]=S(R[4*i+k],v));sources.push(src);}
const raw=R.slice(),constraints=[];if(node)for(let i=0;i<nc;i++){const q=ph.prim(u[i]),put=(k,v)=>{R[4*i+k]=v;constraints.push(4*i+k);};if(bnodes[i].wall){put(1,q.u);put(2,q.v);if(thermal!=='adiabatic')put(3,S(q.T,p[4]));else{let nn=[c(0),c(0)];top.faces.forEach((f,k)=>{if(f.L===i&&f.R<0&&f.bc==='wall')nn=nn.map((v,j)=>A(v,g.faces[k].n[j]));});const len=pow(dot(nn,nn),.5),n=nn.map(v=>D(v,len)),gg=ph.gradient(u[i],rec(i,g.cent[i]).G);put(3,B(c(-1),B(ph.transport(q.T,p).kappa,dot(gg.T,n))));}}else if(bnodes[i].inflow){const z=ph.prim(prescribed(g.cent[i],Math.hypot(...grid.X[i])<1.1));put(0,S(q.rho,z.rho));put(1,S(q.u,z.u));put(2,S(q.v,z.v));if(!bnodes[i].outflow)put(3,S(q.T,z.T));}}
return {R,J,raw,local,sources,constraints,force,pressureForce,viscousForce,heat,g,coeff};}
function linearize(U,p=params,space=order,full=true){budget();const t=Q.tape(),u=U.map(t.op.input),pp=p.map(t.op.input),out=evaluate(t.op,u,pp,space,full?false:p),rv=a=>a.map(i=>t.val[i]),extract=(v,d)=>Array.isArray(v)?v.map(x=>extract(x,d)):v&&typeof v==='object'?Object.fromEntries(Object.entries(v).map(([k,x])=>[k,extract(x,d)])):d[v];return {R:rv(out.R),J:t.val[out.J],size:t.val.length,local:extract(out.local,t.val),jvp:(v,dp=zeros(np))=>{if(!full&&dp.slice(6).some(x=>x))throw Error('State-only tape cannot differentiate geometry');const d=t.forward([...v,...dp]);return {R:rv(out.R).map((_,i)=>d[out.R[i]]),J:d[out.J],local:extract(out.local,d),sources:extract(out.sources,d),raw:out.raw.map(i=>d[i])};},vjp:(w,j=0)=>{const d=t.reverse([...out.R,out.J],[...w,j]);return {U:d.slice(0,nd),p:d.slice(nd)};},faceVjp:(f,w,j=0)=>{const d=t.reverse([...out.local[f].H,out.local[f].J],[...w,j]);return {U:d.slice(0,nd),p:d.slice(nd)};},constraintVjp:w=>t.reverse(out.constraints.map(i=>out.R[i]),out.constraints.map(i=>w[i])).slice(0,nd)};}
function precondition(U,p=params,cachedA=null){const A=cachedA||assemble(linearize(U,p,1,false),nd),engineering=[],build=(tr=false)=>engineering[+tr]||(engineering[+tr]=E.build(tr?E.transpose(E.rows(A)):A)),blocks=Array.from({length:nc},(_,i)=>factor(A.slice(4*i,4*i+4).map(r=>r.slice(4*i,4*i+4)))),bt=Array.from({length:nc},(_,i)=>factor(transpose(A.slice(4*i,4*i+4).map(r=>r.slice(4*i,4*i+4)))));return {A,engineering:build,applyEngineering:(r,tr=false)=>build(tr).apply(r),apply:(r,tr=false)=>{const z=zeros(nd);for(let it=0;it<nc;it++){const i=tr?nc-1-it:it,b=r.slice(4*i,4*i+4);for(let j=tr?i+1:0;tr?j<nc:j<i;j++)for(let k=0;k<4;k++)for(let l=0;l<4;l++)b[k]-=(tr?A[4*j+l][4*i+k]:A[4*i+k][4*j+l])*z[4*j+l];lu(tr?bt[i]:blocks[i],b).forEach((v,k)=>z[4*i+k]=v);}return z;}};}
function* stationary(L,b,pre,tr=false,tol=1e-10){const op=v=>tr?L.vjp(v).U:L.jvp(v).R;let x=zeros(nd),history=[norm(b)];for(let k=0;k<500;k++){yield {iteration:k};const ax=op(x),r=b.map((v,i)=>v-ax[i]),rn=norm(r);if(rn<tol)return {x,history,residual:rn,converged:true};if(!Number.isFinite(rn)||rn>1e8)break;const d=pre.apply(r,tr);x=x.map((v,i)=>v+.5*d[i]);history.push(norm(op(x).map((v,i)=>v-b[i])));}const residual=norm(op(x).map((v,i)=>v-b[i]));return {x,history,residual,converged:residual<tol};}
function linear(L,b,pre,tr=false,method='fgmres',tol=1e-10){const op=v=>tr?L.vjp(v).U:L.jvp(v).R;if(method==='lu'){const A=assemble(L,nd),x=lu(factor(tr?transpose(A):A),b),r=norm(op(x).map((v,i)=>v-b[i]));return {x,residual:r,history:[norm(b),r],converged:r<tol*10};}let x=zeros(nd),history=[norm(b)];if(method==='gs'){const it=stationary(L,b,pre,tr,tol);let r;do{r=it.next();}while(!r.done);return r.value;}
for(let cycle=0;cycle<12;cycle++){budget();const ax=op(x),r=b.map((v,i)=>v-ax[i]);if(norm(r)<tol)return {x,history,residual:norm(r),converged:true};const g=Q.gmres(op,r,r=>{budget();return method==='fgmres'?pre.applyEngineering(r,tr):pre.apply(r,tr);},tol,Math.min(nd,100));x=x.map((v,i)=>v+g.x[i]);history.push(...g.history.slice(1));if(!Number.isFinite(history.at(-1)))break;}const residual=norm(op(x).map((v,i)=>v-b[i]));return {x,history,residual,converged:residual<tol*5};}
function initial(p=params){const g=prepare(p).g,ph=P.physics(Q.real,variable);if(manufactured)return g.cent.flatMap((x,i)=>node?field(Q.real,x):g.quadrature[i].reduce((s,q)=>s.map((v,k)=>v+q.w*field(Q.real,q.x)[k]/g.vol[i]),zeros(4)));const free=ph.cons(1,p[0]*Math.sqrt(1.4)*Math.cos(p[1]),p[0]*Math.sqrt(1.4)*Math.sin(p[1]),1);return g.cent.flatMap((x,i)=>{const q=ph.prim(free),r=Math.hypot(...x),f=Math.max(0,Math.min(1,(r-.55)/1.35));return ph.cons(1,q.u*f,q.v*f,1+(p[4]-1)*(1-f));});}
function valid(U,p=params){const ph=P.physics(Q.real,variable);if(!U.every(Number.isFinite))return false;return Array.from({length:nc},(_,i)=>ph.prim(U.slice(4*i,4*i+4))).every(q=>q.rho>.15&&q.T>.3&&Math.hypot(q.u,q.v)<.8*Math.sqrt(1.4*q.T));}
function diagnostics(U,p=params){const e=evaluate(Q.real,U,p,order,p),ph=P.physics(Q.real,variable);let minRho=Infinity,minT=Infinity,maxWave=0,minBoundaryMargin=Infinity;for(let fi=0;fi<top.faces.length;fi++){const f=top.faces[fi];for(const s of e.local[fi].samples){for(const u of [s.UL,s.UR,s.viscU,s.viscUR]){const q=ph.prim(u);minRho=Math.min(minRho,q.rho);minT=Math.min(minT,q.T);maxWave=Math.max(maxWave,Math.abs(q.u*s.n[0]+q.v*s.n[1])+Math.sqrt(1.4*q.T));}if(f.R<0&&!(wallBody&&f.bc==='wall')){const q=ph.prim(s.UR),un=q.u*s.n[0]+q.v*s.n[1];minBoundaryMargin=Math.min(minBoundaryMargin,(f.inflow?-1:1)*un);}}}return {minRho,minT,maxWave,minBoundaryMargin,reproductionError:prepare(p).reproductionError,supported:minRho>.1&&minT>.2&&maxWave<3&&minBoundaryMargin>=-1e-10};}
function solve(p=params,start,method='fgmres',tol=1e-10){if(!(tol>0&&Number.isFinite(tol)))throw Error('Invalid nonlinear tolerance');let U=start?start.slice():initial(p),history=[],states=[];for(let k=0;k<35;k++){const L=linearize(U,p,order,false),r=norm(L.R);history.push(r);states.push(U.slice());if(r<tol){const check=diagnostics(U,p);if(!check.supported)throw Error('Outside supported positive subsonic boundary branch: margin='+check.minBoundaryMargin+', wave='+check.maxWave);return {U,J:L.J,history,states,converged:true,diagnostics:check};}const pre=method==='lu'?null:precondition(U,p),d=linear(L,L.R.map(v=>-v),pre,false,method,Math.max(Math.min(1e-12,tol*.01),Math.min(1e-9,r*1e-3)));if(!d.converged)throw Error('Newton linear solve failed '+d.residual);let a=1,ok=false;while(a>1e-7){const v=U.map((x,i)=>x+a*d.x[i]);if(valid(v,p)){const e=evaluate(Q.real,v,p,order,p);if(e.local.every(f=>f.samples.every(s=>[s.UL,s.UR,s.viscU,s.viscUR].every(U=>{const q=P.physics(Q.real,variable).prim(U);return q.rho>.1&&q.T>.2;})))&&norm(e.R)<(1-1e-4*a)*r){U=v;ok=true;break;}}a*=.5;}if(!ok)throw Error('No admissible Newton step '+r);}throw Error('Nonlinear budget exhausted');}
function sensitivity(sol,p=params,k=0,full=true){const L=linearize(sol.U,p,order,full),pre=precondition(sol.U,p),j=L.vjp(zeros(nd),1),adj=linear(L,j.U.map(v=>-v),pre,true),dp=zeros(np);dp[k]=1;const tangent=linear(L,L.jvp(zeros(nd),dp).R.map(v=>-v),pre);if(!adj.converged||!tangent.converged)throw Error('Derivative solve failed');return {L,pre,adjoint:adj,tangent,gradient:L.vjp(adj.x,1).p,directional:L.jvp(tangent.x,dp).J};}
function complexGradient(sol,k,p=params){const h=1e-25,pp=p.map((v,i)=>[v,i===k?h:0]),U=sol.U.map(v=>[v,0]),L=linearize(sol.U,p,order,false),pre=precondition(sol.U,p);for(let it=0;it<4;it++){const e=evaluate(complex,U,pp),r=e.R.map(v=>v[1]/h);if(norm(r)<1e-9)return {gradient:e.J[1]/h,residual:norm(r)};const d=linear(L,r.map(v=>-v),pre,false,'fgmres',1e-11);if(!d.converged)throw Error('Complex correction failed');U.forEach((v,i)=>v[1]+=h*d.x[i]);}throw Error('Complex residual failed');}
return {node,level,order,variable,mode,objective,thermal,grid,top,nc,nd,np,params,base,bnodes,field,geometry,prepare,evaluate:(U,p=params)=>evaluate(Q.real,U,p,order,p),evaluateWith:evaluate,linearize,precondition,linear,stationary,initial,valid,diagnostics,solve,sensitivity,complexGradient,physics:P.physics(Q.real,variable)};
}
const api={model,...Q,complex};if(typeof module!=='undefined')module.exports=api;else root.NS2D=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: study.js
(function(root){'use strict';const C=typeof module!=='undefined'?require('./core'):root.NS2D,P=typeof module!=='undefined'?require('./physics'):root.NS2Physics,N=typeof module!=='undefined'?require('../experiments/numerics'):root.EduNumerics;
function reference(m){const g=m.geometry(m.params);return g.cent.flatMap((x,i)=>m.node?P.exact(C.real,x):g.quadrature[i].reduce((s,q)=>s.map((v,k)=>v+q.w*P.exact(C.real,q.x)[k]/g.vol[i]),[0,0,0,0]));}
function accuracy(node,variable,levels=[0,1,2],mu=.15,Pr=.72){const rows=[];for(const order of [1,2]){let prev;for(const level of levels){const m=C.model({node,variable,level,order,mu,Pr,mode:'mms'}),s=m.solve(),ref=reference(m),g=m.geometry(m.params),error=Math.sqrt(g.vol.reduce((sum,v,i)=>sum+v*ref.slice(4*i,4*i+4).reduce((sum,r,k)=>sum+(s.U[4*i+k]-r)**2,0),0)/g.vol.reduce((a,b)=>a+b,0));rows.push({level,order,nc:m.nc,nd:m.nd,error,rate:prev?Math.log2(prev/error):null,residual:s.history.at(-1)});prev=error;}}return rows;}
function trace(m,sol,kind,L=null,seed=null){
if(!['residual','tangent','adjoint'].includes(kind))throw Error('Unknown trace kind');
if(seed&&(seed.length!==m.nd||!seed.every(Number.isFinite)))throw Error('Invalid trace seed');
L=L||m.linearize(sol.U);const raw=m.evaluate(sol.U),v=seed&&kind==='tangent'?seed.slice():sol.U.map((_,i)=>Math.sin(i+.3)),w=seed&&kind==='adjoint'?seed.slice():v.map((_,i)=>Math.cos(i+.7)),d=kind==='tangent'?L.jvp(v):null,out=N.zeros(m.nd),steps=[];
const step=(z,e,face,line,details=[],delta=[],inputs=[],outputs=[],q=-1)=>{const before=out.slice();for(const [i,x,replace]of delta)out[i]=replace?x:out[i]+x;steps.push({z,e,face,line,stage:line-1,details,values:out.slice(),before,inputs,outputs,q});};
const stencil=f=>[...new Set([f.L,...m.top.stencil[f.L],...(f.R>=0?[f.R,...m.top.stencil[f.R]]:[])])];
step('清零输出;几何与拟合系数已准备','Zero output; geometry and fit coefficients are ready',-1,2);
if(kind==='adjoint'){
const masked=w.slice();raw.constraints.forEach(i=>masked[i]=0);
step('先反传强边界条件,再屏蔽旧平衡行','Reverse strong constraints, then mask old balance rows',-1,3,raw.constraints.map(i=>['row',i]),L.constraintVjp(w).map((x,i)=>[i,x]),[...new Set(raw.constraints.map(i=>Math.floor(i/4)))],Array.from({length:m.nc},(_,i)=>i));
for(let f=m.top.faces.length-1;f>=0;f--){const face=m.top.faces[f],ins=[face.L,...(face.R>=0?[face.R]:[])],eta=[0,1,2,3].map(k=>masked[4*face.L+k]-(face.R>=0?masked[4*face.R+k]:0)),b=L.faceVjp(f,eta).U;
step('选面;只读取未覆盖平衡行的权重','Select face; read only unmasked balance weights',f,4,[],[],ins);
step('左权重减右权重,得到四分量面种子','Left minus right weights form the four-component face seed',f,5,eta.map((v,k)=>['H_b['+k+']',v]),[],ins);
step('对实际面运算逆向应用链式法则','Reverse the actual face computation by the chain rule',f,6,b.map((v,i)=>['Uf_b['+i+']',v]).filter(a=>Math.abs(a[1])>1e-12),[],ins);
for(const i of stencil(face))step('累加到状态 '+i+' 的四个分量','Accumulate into all four components of state '+i,f,7,b.slice(4*i,4*i+4).map((x,k)=>['U_b['+k+'] +=',x]),b.slice(4*i,4*i+4).map((x,k)=>[4*i+k,x]),ins,[i]);
}
step('源项与状态无关,状态反传为零','Sources are state-independent; their state reverse is zero',-1,8);
step('返回完整转置乘积','Return the complete transpose product',-1,9);
}else{
for(let f=0;f<m.top.faces.length;f++){const z=(d||raw).local[f],face=m.top.faces[f],ins=stencil(face);step('选面并确定左右状态位置','Select the face and its left/right states',f,3,[['L',face.L],['R',face.R]],[],ins);
for(let q=0;q<z.samples.length;q++){const a=z.samples[q],dd=kind==='tangent'?'_d':'';
step('求积点 '+q+':重构两侧迹与梯度','Quadrature point '+q+': reconstruct traces and gradients',f,4,[...a.UL.map((v,k)=>['U−'+dd+'['+k+']',v]),...a.UR.map((v,k)=>['U+'+dd+'['+k+']',v]),...a.Gu.map((v,k)=>['Gᵘ'+dd+'['+k+']',v]),...a.Gv.map((v,k)=>['Gᵛ'+dd+'['+k+']',v]),...a.GT.map((v,k)=>['Gᵀ'+dd+'['+k+']',v])],[],ins,[],q);
step('求积点 '+q+':物性、应力、热流与边界闭合','Quadrature point '+q+': transport, stress, heat and boundary closure',f,5,[['μ'+dd,a.mu],['κ'+dd,a.kappa],...a.tau.map((v,k)=>['τ'+dd+'['+k+']',v]),[dd?'qn_d':'q·n',a.qn]],[],ins,[],q);
}
step('汇总全部子面及求积点的加权通量','Sum weighted fluxes over all subfaces and quadrature points',f,6,z.H.map((v,k)=>[(kind==='tangent'?'H_d':'H')+'['+k+']',v]),[],ins);
step('左侧加上面通量','Add face flux to the left volume',f,7,z.H.map((v,k)=>['+'+(kind==='tangent'?'H_d':'H')+'['+k+']',v]),z.H.map((v,k)=>[4*face.L+k,v]),ins,[face.L]);
if(face.R>=0)step('右侧减去同一个面通量','Subtract the same flux from the right volume',f,8,z.H.map((v,k)=>['−'+(kind==='tangent'?'H_d':'H')+'['+k+']',-v]),z.H.map((v,k)=>[4*face.R+k,-v]),ins,[face.R]);
}
const ss=(d||raw).sources;step('减去全部控制体的源项求积','Subtract source integrals in every volume',-1,9,[],ss.flatMap((a,i)=>a.map((v,k)=>[4*i+k,-v])),[],Array.from({length:m.nc},(_,i)=>i));
if(raw.constraints.length)step('覆盖强边界约束行;不是继续加通量','Replace strong boundary rows; do not add fluxes to them',-1,10,raw.constraints.map(i=>['row',i]),raw.constraints.map(i=>[i,(d||raw).R[i],true]),[],[...new Set(raw.constraints.map(i=>Math.floor(i/4)))]);
step('返回完整离散运算','Return the complete discrete operation',-1,11);
}
const expected=kind==='adjoint'?L.vjp(w).U:kind==='tangent'?d.R:raw.R;const masked=w.slice();raw.constraints.forEach(i=>masked[i]=0);return {steps,v,w,masked,expected,error:N.norm(out.map((x,i)=>x-expected[i]))};
}
// Replay the same restarted, right-preconditioned GMRES used by core.linear.
// Q.gmres already records genuine iterates; never interpolate solver fields.
function linearTrace(m,L,b,pre,transposed=false,tol=1e-10){
const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath,op=x=>transposed?L.vjp(x).U:L.jvp(x).R;let x=N.zeros(m.nd),states=[x.slice()],residuals=[N.norm(b)];
for(let cycle=0;cycle<12;cycle++){const ax=op(x),r=b.map((v,i)=>v-ax[i]);if(N.norm(r)<tol)break;const base=x.slice(),a=Q.gmres(op,r,y=>{if(root.NS2Budget)root.NS2Budget.check();return pre.applyEngineering(y,transposed);},tol,Math.min(m.nd,100));for(const dx of a.states.slice(1)){const v=base.map((z,i)=>z+dx[i]);states.push(v);residuals.push(N.norm(op(v).map((z,i)=>z-b[i])));}x=base.map((v,i)=>v+a.x[i]);if(!Number.isFinite(residuals.at(-1)))break;}
const residual=N.norm(op(x).map((v,i)=>v-b[i]));return {x,states,residuals,residual,converged:residual<tol*5};
}
const api={reference,accuracy,trace,linearTrace};if(typeof module!=='undefined')module.exports=api;else root.NS2Study=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: continuous_check.js
(function(root){'use strict';
const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath,P=typeof module!=='undefined'?require('./physics'):root.NS2Physics;
// Prescribed smooth fields: a verification identity, not a converged flow.
function fields(o,x,component=-1,amplitude=1){const {c,add:A,mul:M}=o,[X,Y]=x,poly=(a,b,d,e,f,g)=>A(A(A(c(a),M(c(b),X)),A(M(c(d),Y),M(c(e),M(X,X)))),A(M(c(f),M(X,Y)),M(c(g),M(Y,Y))));
return {W:[poly(1,.06,.03,.01,.01,0),poly(.22,.04,-.03,0,.02,.01),poly(.05,.02,.04,.01,-.01,0),poly(1,.08,.05,.02,.03,.01)],lambda:[poly(.2,.17,-.08,.04,.06,0),poly(-.1,.07,.11,.03,-.02,.02),poly(.13,-.05,.09,0,.04,-.03),poly(.3,.12,-.07,.05,.02,.04)],dW:[poly(.07,.03,-.02,0,.01,0),poly(-.04,.02,.06,.01,0,-.02),poly(.03,-.05,.02,.02,.01,0),poly(.09,.04,-.03,.02,-.01,.01)].map((v,i)=>component<0||component===i?M(c(amplitude),v):c(0))};}
function data(o,x,component=-1,amplitude=1){const j=P.jets(o),f=fields(j,x.map((v,k)=>[v,o.c(k===0?1:0),o.c(k===1?1:0)]),component,amplitude),z={};for(const key of ['W','lambda','dW']){z[key]=f[key].map(a=>a[0]);z['G'+key]=f[key].map(a=>a.slice(1));}return z;}
function flux(o,x,epsilon,variable,component,amplitude){const {c,add:A,mul:M,sub:S}=o,z=data(o,x,component,amplitude),w=z.W.map((v,i)=>A(v,M(epsilon,z.dW[i]))),g=z.GW.map((r,i)=>r.map((v,k)=>A(v,M(epsilon,z.GdW[i][k])))),ph=P.physics(o,variable),U=ph.cons(...w),pr=ph.prim(U),G={u:g[1],v:g[2],T:g[3]},par=[c(0),c(0),c(.2),c(.72)];return [0,1].map(k=>{const n=[c(k===0?1:0),c(k===1?1:0)],fc=ph.flux(U,n),fv=ph.viscous(pr,G,n,par).F;return fc.map((v,i)=>S(v,fv[i]));});}
function deltaFlux(o,x,variable=false,component=-1,amplitude=1){const d=P.jets(o);return flux(d,x.map(d.constant),[o.c(0),o.c(1),o.c(0)],variable,component,amplitude).map(f=>f.map(v=>v[1]));}
// Independently expanded A.6 coefficients before the final spatial divergence.
function coefficients(o,x,variable=false){const {c,add:A,sub:S,mul:M,div:D}=o,z=data(o,x),[rho,u,v,T]=z.W,vel=[u,v],a=z.Glambda[0],B=[z.Glambda[1],z.Glambda[2]],cc=z.Glambda[3],sum=xs=>xs.reduce(A,c(0)),dot=(a,b)=>sum(a.map((v,i)=>M(v,b[i]))),ph=P.physics(o,variable),mu=ph.transport(T,[c(0),c(0),c(.2),c(.72)]).mu,kappa=D(M(c(3.5),mu),c(.72)),muT=variable?M(mu,S(D(c(1.5),T),D(c(1),A(T,c(.4))))):c(0),kT=D(M(c(3.5),muT),c(.72)),div=A(z.GW[1][0],z.GW[2][1]),strain=[0,1].map(i=>[0,1].map(j=>S(A(z.GW[i+1][j],z.GW[j+1][i]),i===j?M(c(2/3),div):c(0)))),stress=strain.map(r=>r.map(s=>M(mu,s))),qq=B.map((r,i)=>r.map((b,j)=>A(b,M(vel[i],cc[j])))),trace=A(qq[0][0],qq[1][1]),Z=qq.map((r,i)=>r.map((q,j)=>M(mu,S(A(q,qq[j][i]),i===j?M(c(2/3),trace):c(0))))),heat=cc.map(v=>M(kappa,v)),K=M(c(.5),dot(vel,vel)),H=A(M(c(3.5),T),K),uc=dot(vel,cc),trB=A(B[0][0],B[1][1]),quad=sum(B.flatMap((r,i)=>r.map((b,j)=>M(M(vel[i],vel[j]),b)))),Er=S(S(S(M(c(-1),dot(vel,a)),quad),M(T,trB)),M(H,uc)),Eu=[0,1].map(i=>sum([M(c(-1),M(rho,a[i])),M(c(-1),M(rho,dot(vel,B[i]))),M(c(-1),M(rho,sum(vel.map((v,k)=>M(v,B[k][i]))))),M(c(-1),M(M(rho,H),cc[i])),M(c(-1),M(M(rho,vel[i]),uc)),dot(stress[i],cc)])),ET=sum([M(c(-1),M(rho,trB)),M(c(-3.5),M(rho,uc)),M(muT,sum(qq.flatMap((r,i)=>r.map((v,j)=>M(v,strain[i][j]))))),M(kT,dot(cc,z.GW[3]))]);return {E0:[Er,...Eu,ET],Z,heat,mu,kappa,stress};}
function point(x,variable=false,component=-1,amplitude=1){const o=Q.real,j=P.jets(o),xx=x.map((v,k)=>[v,k===0?1:0,k===1?1:0]),df=deltaFlux(j,xx,variable,component,amplitude),dR=[0,1,2,3].map(i=>df[0][i][1]+df[1][i][2]),z=data(o,x,component,amplitude),co=coefficients(j,xx,variable),E=co.E0.map(a=>a[0]);for(let i=0;i<2;i++)E[i+1]-=co.Z[i][0][1]+co.Z[i][1][2];E[3]-=co.heat[0][1]+co.heat[1][2];return {lhs:Q.dot(z.lambda,dR),volume:Q.dot(E,z.dW),E,dR};}
function edge(x,n,variable=false,component=-1,amplitude=1){const z=data(Q.real,x,component,amplitude),co=coefficients(Q.real,x,variable),df=deltaFlux(Q.real,x,variable,component,amplitude),normalFlux=Q.dot(z.lambda,z.lambda.map((_,i)=>n[0]*df[0][i]+n[1]*df[1][i])),mechanical=Q.dot(co.Z.map(r=>Q.dot(r,n)),z.dW.slice(1,3)),thermal=Q.dot(co.heat,n)*z.dW[3];return {flux:normalFlux,mechanical,thermal,total:normalFlux+mechanical+thermal};}
function gauss(n){const out=[];for(let i=0;i<n;i++){let z=Math.cos(Math.PI*(i+.75)/(n+.5)),dp=0;for(let k=0;k<40;k++){let p=1,pm=0;for(let j=1;j<=n;j++){const prev=pm;pm=p;p=((2*j-1)*z*pm-(j-1)*prev)/j;}dp=n*(z*p-pm)/(z*z-1);const dz=p/dp;z-=dz;if(Math.abs(dz)<2e-15)break;}let p=1,pm=0;for(let j=1;j<=n;j++){const prev=pm;pm=p;p=((2*j-1)*z*pm-(j-1)*prev)/j;}dp=n*(z*p-pm)/(z*z-1);out.push([(z+1)/2,1/((1-z*z)*dp*dp)]);}return out.sort((a,b)=>a[0]-b[0]);}
function integrate({n=4,variable=false,component=-1,amplitude=1,omitThermal=false}={}){const g=gauss(n);let lhs=0,volume=0;const sides=Array.from({length:4},()=>({flux:0,mechanical:0,thermal:0,total:0}));for(const [x,wx] of g)for(const [y,wy] of g){const a=point([x,y],variable,component,amplitude);lhs+=wx*wy*a.lhs;volume+=wx*wy*a.volume;}for(const [t,w] of g){const pts=[[0,t],[1,t],[t,0],[t,1]],normals=[[-1,0],[1,0],[0,-1],[0,1]];pts.forEach((x,i)=>{const e=edge(x,normals[i],variable,component,amplitude);for(const k of Object.keys(e))sides[i][k]+=w*e[k];});}const boundary=sides.reduce((s,v)=>s+v.total-(omitThermal?v.thermal:0),0),rhs=volume+boundary;return {lhs,volume,boundary,rhs,error:Math.abs(lhs-rhs),relative:Math.abs(lhs-rhs)/Math.max(Math.abs(lhs),Math.abs(volume)+Math.abs(boundary),1e-30),sides};}
// Boundary expression A.7, before applying homogeneous conditions.
function boundaryForm({rho,u,T,lambda,n,t,Zn,dnChi,kappa,dr,du,dT,dt,dq,d=[0,0],eta=0}){const E=2.5*T+.5*Q.dot(u,u),un=Q.dot(u,n),[alpha,b1,b2,chi]=lambda,beta=[b1,b2],A=alpha+Q.dot(beta,u)+chi*E;
const terms=[un*A*dr,Q.dot(n.map((v,i)=>rho*A*v+rho*un*(beta[i]+chi*u[i])+chi*t[i]+Zn[i]),du),(rho*2.5*un*chi+kappa*dnChi)*dT,Q.dot(beta.map((b,i)=>b+chi*u[i]),dt),chi*dq,Q.dot(d,dt)+eta*dq];return {terms,total:terms.reduce((a,b)=>a+b,0)};}
function boundaryExample(kind='isothermal',objective='forcex',incorrect=false){const n=[.6,.8],wall=kind==='isothermal'||kind==='adiabatic',d=objective==='forcex'?[1,0]:objective==='forcey'?[0,1]:[0,0],eta=objective==='heat'?1:0,z={rho:1.1,u:wall?[0,0]:[.3,.08],T:1.04,lambda:[.27,-.12,.09,.16],n,t:[.65,.74],Zn:[.035,-.026],dnChi:.07,kappa:.2*3.5/.72,dr:.08,du:[-.05,.03],dT:.06,dt:[.12,-.09],dq:.11,d:wall?d:[0,0],eta:wall?eta:0};
if(kind==='inflow'){z.dr=0;z.du=[0,0];z.dT=0;z.lambda=[.27,0,0,0];}
if(kind==='outflow'){z.dt=[0,0];z.dq=0;const E=2.5*z.T+.5*Q.dot(z.u,z.u),un=Q.dot(z.u,n),b=z.lambda.slice(1,3),chi=z.lambda[3];z.lambda[0]=-Q.dot(b,z.u)-chi*E;z.Zn=b.map((v,i)=>-z.rho*un*(v+chi*z.u[i])-chi*z.t[i]);z.dnChi=-z.rho*2.5*un*chi/z.kappa;}
if(wall){z.du=[0,0];z.lambda[1]=-d[0];z.lambda[2]=-d[1];if(kind==='isothermal'){z.dT=0;z.lambda[3]=-eta;}else{z.dq=0;z.dnChi=0;}}
if(incorrect){z.lambda=[0,0,0,0];z.Zn=[0,0];z.dnChi=0;}
return {data:z,...boundaryForm(z)};}
const api={fields,data,flux,deltaFlux,coefficients,point,edge,gauss,integrate,boundaryForm,boundaryExample};if(typeof module!=='undefined')module.exports=api;else root.NS2ContinuousCheck=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: continuous_bvp.js
/* Independent strong-form continuous Navier–Stokes boundary-value solver.
Polynomial least-squares collocation on the lesson's polygonal O domain.
No import of core.js, residual tape, numerical flux, or discrete transpose. */
(function(root){'use strict';
const G=typeof module!=='undefined'?require('./geometry'):root.NS2Geometry;
const CK=typeof module!=='undefined'?require('./continuous_check'):root.NS2ContinuousCheck;
const zero=()=>[0,0,0,0,0,0],constant=v=>[v,0,0,0,0,0],add=(a,b)=>a.map((v,i)=>v+b[i]),scale=(a,v)=>a.map(x=>x*v),sub=(a,b)=>add(a,scale(b,-1));
const mul=(a,b)=>[a[0]*b[0],a[1]*b[0]+a[0]*b[1],a[2]*b[0]+a[0]*b[2],a[3]*b[0]+2*a[1]*b[1]+a[0]*b[3],a[4]*b[0]+a[1]*b[2]+a[2]*b[1]+a[0]*b[4],a[5]*b[0]+2*a[2]*b[2]+a[0]*b[5]];
const unary=(a,f,d,dd)=>[f,d*a[1],d*a[2],d*a[3]+dd*a[1]**2,d*a[4]+dd*a[1]*a[2],d*a[5]+dd*a[2]**2];
const pow=(a,p)=>p===0?constant(1):p===1?a.slice():unary(a,a[0]**p,p*a[0]**(p-1),p*(p-1)*a[0]**(p-2));
const sin=a=>unary(a,Math.sin(a[0]),Math.cos(a[0]),-Math.sin(a[0])),cos=a=>unary(a,Math.cos(a[0]),-Math.sin(a[0]),-Math.cos(a[0]));
const derivative=(a,j)=>j===0?[a[1],a[3],a[4],0,0,0]:[a[2],a[4],a[5],0,0,0],dot=(a,b)=>a.reduce((s,v,i)=>add(s,mul(v,b[i])),zero());
const jets={c:constant,add,sub,mul,div:(a,b)=>mul(a,pow(b,-1)),pow,sin,cos};
function coords(x){return [[x[0],1,0,0,0,0],[x[1],0,1,0,0,0]];}
function exactBase(x,wall='open'){const [X,Y]=coords(x),f=(a,b)=>add(scale(X,a),scale(Y,b));let W=[add(constant(1),scale(sin(f(.7,.4)),.025)),add(constant(.22),scale(sin(f(.5,.6)),.025)),add(constant(.055),scale(cos(f(.8,-.3)),.01)),add(constant(1),scale(sin(f(.6,.7)),.04))];if(wall!=='open'){const c=wallCutoff(x);W[1]=mul(W[1],c);W[2]=mul(W[2],c);W[3]=add(constant(1),mul(sub(W[3],constant(1)),c));}return W;}
function wallCutoff(x){const xx=coords(x),r2=dot(xx,xx),z=scale(sub(r2,constant(.85**2)),1/(1.9**2-.85**2));return z[0]>0?pow(z,3):zero();}
function reference(x,opt){return exactBase(x,opt.boundary||'open');}
function variation(x,opt){const [X,Y]=coords(x),V=[scale(cos(sub(scale(X,.4),scale(Y,.3))),.01),scale(sin(add(scale(X,.5),scale(Y,.2))),.02),scale(cos(add(scale(X,.3),scale(Y,.6))),.01),scale(sin(add(scale(X,.45),scale(Y,.55))),.025)];if(opt.boundary!=='open'){const c=wallCutoff(x);for(let i=1;i<4;i++)V[i]=mul(V[i],c);}return V;}
function family(x,opt){const W=reference(x,opt),V=variation(x,opt),a=opt.parameter==='family'?(opt.amplitude||0):0;return W.map((v,i)=>add(v,scale(V[i],a)));}
function exactAdjoint(x){const [X,Y]=coords(x);return [scale(sin(add(scale(X,.45),scale(Y,.3))),.12),scale(cos(sub(scale(X,.4),scale(Y,.25))),.08),scale(sin(sub(scale(Y,.5),scale(X,.2))),.06),scale(cos(add(scale(X,.35),scale(Y,.55))),.09)];}
function physical(W,opt){if(!(W[0][0]>0&&W[3][0]>0&&opt.mu>0&&opt.Pr>0))throw Error('Nonpositive continuous density, temperature or transport');const rho=W[0],u=W.slice(1,3),T=W[3],p=mul(rho,T),E=add(scale(T,2.5),scale(dot(u,u),.5)),H=add(E,T),mu=opt.variable?scale(jets.div(pow(T,1.5),add(T,constant(.4))),1.4*opt.mu):constant(opt.mu),kappa=scale(mu,3.5/opt.Pr),muT=opt.variable?mul(mu,sub(scale(pow(T,-1),1.5),pow(add(T,constant(.4)),-1))):zero(),kappaT=scale(muT,3.5/opt.Pr),div=add(derivative(u[0],0),derivative(u[1],1)),strain=[0,1].map(i=>[0,1].map(j=>sub(add(derivative(u[i],j),derivative(u[j],i)),scale(div,i===j?2/3:0)))),tau=strain.map(r=>r.map(s=>mul(mu,s)));return {rho,u,T,p,E,H,mu,kappa,muT,kappaT,strain,tau};}
function primal(W,opt){const a=physical(W,opt),F=[0,1].map(j=>[mul(a.rho,a.u[j]),...a.u.map((u,i)=>sub(add(mul(mul(a.rho,u),a.u[j]),i===j?a.p:zero()),a.tau[i][j])),sub(sub(mul(mul(a.rho,a.H),a.u[j]),dot(a.u,a.tau.map(t=>t[j]))),mul(a.kappa,derivative(a.T,j)))]);return [0,1,2,3].map(i=>F[0][i][1]+F[1][i][2]);}
// Linearize the continuous physical flux, before any spatial approximation.
function tangent(W,V,opt){const a=physical(W,opt),dr=V[0],du=V.slice(1,3),dT=V[3],dp=add(mul(a.T,dr),mul(a.rho,dT)),dH=add(scale(dT,3.5),dot(a.u,du)),ddiv=add(derivative(du[0],0),derivative(du[1],1)),dtau=a.tau.map((r,i)=>r.map((_,j)=>add(mul(mul(a.muT,dT),a.strain[i][j]),mul(a.mu,sub(add(derivative(du[i],j),derivative(du[j],i)),scale(ddiv,i===j?2/3:0)))))),dq=[0,1].map(j=>scale(add(mul(mul(a.kappaT,dT),derivative(a.T,j)),mul(a.kappa,derivative(dT,j))),-1));
const dF=[0,1].map(j=>[add(mul(dr,a.u[j]),mul(a.rho,du[j])),...a.u.map((u,i)=>sub(add(add(mul(dr,mul(u,a.u[j])),mul(a.rho,add(mul(du[i],a.u[j]),mul(u,du[j])))),i===j?dp:zero()),dtau[i][j])),add(sub(sub(add(add(mul(dr,mul(a.H,a.u[j])),mul(a.rho,mul(dH,a.u[j]))),mul(a.rho,mul(a.H,du[j]))),dot(du,a.tau.map(t=>t[j]))),dot(a.u,dtau.map(t=>t[j]))),dq[j])]);return {R:[0,1,2,3].map(i=>dF[0][i][1]+dF[1][i][2]),dp,dtau,dq,F:dF.map(f=>f.map(v=>v[0]))};}
function primalBoundary(W,n,kind,opt){if(kind==='inflow')return W.map(v=>v[0]);const a=physical(W,opt),qn=-a.kappa[0]*(W[3][1]*n[0]+W[3][2]*n[1]);if(kind==='isothermal')return [W[1][0],W[2][0],W[3][0]];if(kind==='adiabatic')return [W[1][0],W[2][0],qn];return [...[0,1].map(i=>a.p[0]*n[i]-a.tau[i][0][0]*n[0]-a.tau[i][1][0]*n[1]),qn];}
function tangentBoundary(W,V,n,kind,opt){if(kind==='inflow')return V.map(v=>v[0]);const t=tangent(W,V,opt),dq=t.dq[0][0]*n[0]+t.dq[1][0]*n[1];if(kind==='isothermal')return [V[1][0],V[2][0],V[3][0]];if(kind==='adiabatic')return [V[1][0],V[2][0],dq];return [...[0,1].map(i=>t.dp[0]*n[i]-t.dtau[i][0][0]*n[0]-t.dtau[i][1][0]*n[1]),dq];}
function adjoint(W,L,opt){const a=physical(W,opt),alpha=L[0],beta=L.slice(1,3),chi=L[3],B=beta.map(b=>[0,1].map(j=>derivative(b,j))),c=[0,1].map(j=>derivative(chi,j)),q=B.map((r,i)=>r.map((b,j)=>add(b,mul(a.u[i],c[j])))),tr=add(q[0][0],q[1][1]),Z=q.map((r,i)=>r.map((v,j)=>mul(a.mu,sub(add(v,q[j][i]),scale(tr,i===j?2/3:0))))),ga=[0,1].map(j=>derivative(alpha,j)),uc=dot(a.u,c);
let er=scale(add(add(dot(a.u,ga),dot(a.u,B.map(r=>dot(a.u,r)))),add(mul(a.T,add(B[0][0],B[1][1])),mul(a.H,uc))),-1);
let ev=[0,1].map(i=>{let v=scale(mul(a.rho,add(add(ga[i],dot(a.u,B[i])),add(dot(a.u,B.map(r=>r[i])),add(mul(a.H,c[i]),mul(a.u[i],uc))))),-1);v=add(v,dot(a.tau[i],c));return v[0]-Z[i][0][1]-Z[i][1][2];});
let et=scale(mul(a.rho,add(add(B[0][0],B[1][1]),scale(uc,3.5))),-1);et=add(et,mul(a.muT,q.flat().reduce((s,v,k)=>add(s,mul(v,a.strain.flat()[k])),zero())));et=add(et,mul(a.kappaT,dot([0,1].map(j=>derivative(a.T,j)),c)));const heat=c.map(v=>mul(a.kappa,v));return {E:[er[0],...ev,et[0]-heat[0][1]-heat[1][2]],Z:Z.map(r=>r.map(v=>v[0])),heat:heat.map(v=>v[0]),a};}
function boundary(W,L,n,kind,opt){const z=adjoint(W,L,opt),a=z.a,rho=a.rho[0],u=a.u.map(v=>v[0]),T=a.T[0],un=u[0]*n[0]+u[1]*n[1],lambda=L.map(v=>v[0]),chi=lambda[3],beta=lambda.slice(1,3),A=lambda[0]+u[0]*beta[0]+u[1]*beta[1]+chi*a.E[0],t=[0,1].map(i=>a.p[0]*n[i]-a.tau[i][0][0]*n[0]-a.tau[i][1][0]*n[1]),qn=-a.kappa[0]*(W[3][1]*n[0]+W[3][2]*n[1]),Zn=z.Z.map(r=>r[0]*n[0]+r[1]*n[1]),hn=z.heat[0]*n[0]+z.heat[1]*n[1];
let B=kind==='inflow'?[beta[0]+chi*u[0],beta[1]+chi*u[1],chi]:kind==='isothermal'?[...beta,chi]:kind==='adiabatic'?[...beta,hn]:[A,...[0,1].map(i=>Zn[i]+rho*un*(beta[i]+chi*u[i])+chi*t[i]),hn+rho*2.5*un*chi];return {B,t,qn,un,Zn,A};}
function domain(n=4,wall='open'){const grid=G.mesh(0),top=G.topology(grid,false),quad=CK.gauss(n),volume=[],edges=[];
for(const cell of grid.C)for(let k=1;k<cell.length-1;k++){const a=grid.X[cell[0]],b=grid.X[cell[k]],c=grid.X[cell[k+1]],det=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);for(const [s,ws]of quad)for(const [t,wt]of quad)volume.push({x:[0,1].map(d=>a[d]+s*(b[d]-a[d])+(1-s)*t*(c[d]-a[d])),w:det*(1-s)*ws*wt});}
for(const e of top.edges.filter(e=>e.R<0)){const a=grid.X[e.a],b=grid.X[e.b],len=Math.hypot(b[0]-a[0],b[1]-a[1]),normal=[(b[1]-a[1])/len,-(b[0]-a[0])/len],kind=e.bc==='wall'&&wall!=='open'?wall:normal[0]*.22+normal[1]*.055<0?'inflow':'outflow';for(const [s,w]of quad)edges.push({x:a.map((v,i)=>v+s*(b[i]-v)),w:w*len,n:normal,kind,tag:e.bc});}return {grid,volume,edges};}
function basis(x,p){const [X,Y]=coords(x).map(v=>scale(v,1/2)),cheb=z=>{const t=[constant(1),z];for(let k=2;k<=p;k++)t.push(sub(scale(mul(z,t[k-1]),2),t[k-2]));return t;},tx=cheb(X),ty=cheb(Y),out=[];for(let d=0;d<=p;d++)for(let i=0;i<=d;i++)out.push(mul(tx[i],ty[d-i]));return out;}
function field(coeff,x,p){const b=basis(x,p),nb=b.length;return [0,1,2,3].map(c=>b.reduce((s,v,i)=>add(s,scale(v,coeff[c*nb+i])),zero()));}
// Column-scaled Householder QR with column pivoting. No normal equations.
function qrFactor(rows){const m=rows.length,n=rows[0].length,A=rows.map(r=>Float64Array.from(r)),sc=new Float64Array(n),perm=Array.from({length:n},(_,i)=>i),tau=new Float64Array(n),norms=new Float64Array(n);if(m<n)throw Error('Underdetermined collocation system');
for(let j=0;j<n;j++){let s=0;for(let i=0;i<m;i++)s+=A[i][j]**2;sc[j]=Math.sqrt(s);if(!sc[j])throw Error('Zero operator column');for(let i=0;i<m;i++)A[i][j]/=sc[j];norms[j]=1;}
for(let k=0;k<n;k++){let pivot=k;for(let j=k+1;j<n;j++)if(norms[j]>norms[pivot])pivot=j;if(pivot!==k){[perm[k],perm[pivot]]=[perm[pivot],perm[k]];[norms[k],norms[pivot]]=[norms[pivot],norms[k]];for(let i=0;i<m;i++){const v=A[i][k];A[i][k]=A[i][pivot];A[i][pivot]=v;}}
let s=0;for(let i=k;i<m;i++)s+=A[i][k]**2;const len=Math.sqrt(s),v=A[k][k],alpha=v>=0?-len:len;if(!Number.isFinite(len)||len<1e-11)throw Error('Rank-deficient continuous BVP basis at column '+k);tau[k]=(alpha-v)/alpha;for(let i=k+1;i<m;i++)A[i][k]/=v-alpha;A[k][k]=alpha;for(let j=k+1;j<n;j++){let t=A[k][j];for(let i=k+1;i<m;i++)t+=A[i][k]*A[i][j];t*=tau[k];A[k][j]-=t;let norm=0;for(let i=k+1;i<m;i++){A[i][j]-=A[i][k]*t;norm+=A[i][j]**2;}norms[j]=norm;}}
return {A,sc,perm,tau,m,n,diagonalRatio:Math.max(...A.slice(0,n).map((r,i)=>Math.abs(r[i])))/Math.min(...A.slice(0,n).map((r,i)=>Math.abs(r[i])))};}
function qrSolve(F,b){const {A,sc,perm,tau,m,n}=F,y=Float64Array.from(b),z=new Float64Array(n),x=new Float64Array(n);for(let k=0;k<n;k++){let v=y[k];for(let i=k+1;i<m;i++)v+=A[i][k]*y[i];v*=tau[k];y[k]-=v;for(let i=k+1;i<m;i++)y[i]-=A[i][k]*v;}for(let i=n-1;i>=0;i--){let v=y[i];for(let j=i+1;j<n;j++)v-=A[i][j]*z[j];z[i]=v/A[i][i];}for(let i=0;i<n;i++)x[perm[i]]=z[i]/sc[perm[i]];return Array.from(x);}
const defaults={degree:5,quadrature:4,mu:.2,Pr:.72,variable:false,manufactured:true,boundaryWeight:10,boundary:'open',parameter:'family',amplitude:0};
function validate(opt){if(!Number.isInteger(opt.degree)||opt.degree<1||opt.degree>12)throw Error('Polynomial degree must be an integer from 1 to 12');if(!Number.isInteger(opt.quadrature)||opt.quadrature<2||opt.quadrature>16)throw Error('Invalid quadrature order');if(!(opt.mu>0&&opt.Pr>0&&opt.boundaryWeight>0))throw Error('Nonpositive continuous transport or boundary weight');if(!['open','isothermal','adiabatic'].includes(opt.boundary))throw Error('Unsupported continuous boundary closure');if(!['family','heating'].includes(opt.parameter))throw Error('Unsupported continuous parameter family');}
function checkPartition(e,W){if(e.kind==='inflow'||e.kind==='outflow'){const un=W[1][0]*e.n[0]+W[2][0]*e.n[1];if(!Number.isFinite(un)||(e.kind==='inflow'?un>=-1e-10:un<=1e-10))throw Error('Invalid frozen continuous inflow/outflow partition');}}
function operatorRows(W,x,p,opt,n,kind){const b=basis(x,p),nb=b.length,nout=n?(kind==='outflow'?4:3):4,rows=Array.from({length:nout},()=>new Float64Array(4*nb));for(let c=0;c<4;c++)for(let k=0;k<6;k++){let L=Array.from({length:4},zero);L[c][k]=1;const value=n?boundary(W,L,n,kind,opt).B:adjoint(W,L,opt).E;value.forEach((v,i)=>{if(v)for(let j=0;j<nb;j++)rows[i][c*nb+j]+=v*b[j][k];});}return rows;}
function adjointRhs(x,opt,W){if(opt.manufactured)return adjoint(reference(x,opt),exactAdjoint(x),opt).E;return [0,0,0,-(W[3][0]-(1+.02*Math.cos(x[0])))];}
function boundaryCoefficients(W,L,n,opt){const z=boundary(W,L,n,'outflow',opt),a=physical(W,opt),rho=W[0][0],chi=L[3][0],u=W.slice(1,3).map(v=>v[0]);return [z.un*z.A,...[0,1].map(i=>rho*z.A*n[i]+rho*z.un*(L[i+1][0]+chi*u[i])+chi*z.t[i]+z.Zn[i]),rho*2.5*z.un*chi+a.kappa[0]*(L[3][1]*n[0]+L[3][2]*n[1])];}
function boundaryRhs(e,W,opt){if(!opt.manufactured)return Array(e.kind==='outflow'?4:3).fill(0);const W0=reference(e.x,opt),L=exactAdjoint(e.x);if(e.kind!=='outflow')return boundary(W0,L,e.n,e.kind,opt).B;const h=boundaryCoefficients(W0,L,e.n,opt),un=W[1][0]*e.n[0]+W[2][0]*e.n[1],A=h[0]/un;return [A,h[1]-W[0][0]*e.n[0]*A,h[2]-W[0][0]*e.n[1]*A,h[3]];}
function objective(evaluate,opt){const d=domain(opt.quadrature+2,opt.boundary);let J=0;for(const q of d.volume){const W=evaluate(q.x);if(opt.manufactured){const W0=reference(q.x,opt),f=adjoint(W0,exactAdjoint(q.x),opt).E;J-=q.w*f.reduce((s,v,i)=>s+v*(W[i][0]-W0[i][0]),0);}else J+=.5*q.w*(W[3][0]-(1+.02*Math.cos(q.x[0])))**2;}if(opt.manufactured)for(const e of d.edges){const W=evaluate(e.x),W0=reference(e.x,opt),L=exactAdjoint(e.x);if(e.kind==='inflow'){const b=boundary(W,L,e.n,e.kind,opt),b0=boundary(W0,L,e.n,e.kind,opt);J-=e.w*(L[3][0]*(b.qn-b0.qn)+[0,1].reduce((s,i)=>s+(L[i+1][0]+L[3][0]*W0[i+1][0])*(b.t[i]-b0.t[i]),0));}else if(e.kind==='isothermal'||e.kind==='adiabatic'){const b=boundary(W,L,e.n,e.kind,opt),b0=boundary(W0,L,e.n,e.kind,opt);J-=e.w*(L[1][0]*(b.t[0]-b0.t[0])+L[2][0]*(b.t[1]-b0.t[1])+(e.kind==='isothermal'?L[3][0]*(b.qn-b0.qn):b0.B[2]*(W[3][0]-W0[3][0])));}else{const h=boundaryCoefficients(W0,L,e.n,opt);J-=e.w*h.reduce((s,v,i)=>s+v*(W[i][0]-W0[i][0]),0);}}return J;}
function assemble(options={}){const opt={...defaults,...options};validate(opt);const d=domain(opt.quadrature,opt.boundary),base=opt.base||(x=>reference(x,opt)),rows=[],rhs=[],groups=[];for(const q of d.volume){const W=base(q.x),a=operatorRows(W,q.x,opt.degree,opt),b=adjointRhs(q.x,opt,W),s=Math.sqrt(q.w);a.forEach((row,i)=>{rows.push(Float64Array.from(row,v=>v*s));rhs.push(b[i]*s);groups.push('pde');});}for(const e of d.edges){const W=base(e.x);checkPartition(e,W);const a=operatorRows(W,e.x,opt.degree,opt,e.n,e.kind),b=boundaryRhs(e,W,opt),s=Math.sqrt(e.w*opt.boundaryWeight);a.forEach((row,i)=>{rows.push(Float64Array.from(row,v=>v*s));rhs.push(b[i]*s);groups.push(e.kind);});}return {opt,d,base,rows,rhs,groups};}
function measure(solution,n){const {opt,coeff,base}=solution,d=domain(n||opt.quadrature+2,opt.boundary);let pde=0,bc=0,err=0,den=0,max=0,bdmax=0;const parts=Object.fromEntries(['inflow','outflow','isothermal','adiabatic'].map(k=>[k,{sum:0,area:0,max:0}]));for(const q of d.volume){const W=base(q.x),L=field(coeff,q.x,opt.degree),v=adjoint(W,L,opt).E,b=adjointRhs(q.x,opt,W);v.forEach((v,i)=>{pde+=q.w*(v-b[i])**2;max=Math.max(max,Math.abs(v-b[i]));});if(opt.manufactured){const exact=exactAdjoint(q.x);L.forEach((v,i)=>{err+=q.w*(v[0]-exact[i][0])**2;den+=q.w*exact[i][0]**2;});}}for(const e of d.edges){const W=base(e.x),a=boundary(W,field(coeff,e.x,opt.degree),e.n,e.kind,opt).B,b=boundaryRhs(e,W,opt),p=parts[e.kind];p.area+=e.w;a.forEach((v,i)=>{const r=v-b[i];bc+=e.w*r*r;p.sum+=e.w*r*r;p.max=Math.max(p.max,Math.abs(r));bdmax=Math.max(bdmax,Math.abs(r));});}return {pdeL2:Math.sqrt(pde),boundaryL2:Math.sqrt(bc),pdeMax:max,boundaryMax:bdmax,relativeError:opt.manufactured?Math.sqrt(err/den):null,parts};}
function solve(options={}){const sys=assemble(options),t=Date.now(),F=qrFactor(sys.rows),coeff=qrSolve(F,sys.rhs),solution={opt:sys.opt,base:sys.base,coeff,rows:sys.rows.length,unknowns:coeff.length,diagonalRatio:F.diagonalRatio,solveMs:Date.now()-t,grid:sys.d.grid};solution.metrics=measure(solution);solution.evaluate=x=>field(coeff,x,solution.opt.degree);return solution;}
function tangentRows(W,x,p,opt,n,kind){const b=basis(x,p),nb=b.length,nout=n?(kind==='inflow'?4:3):4,rows=Array.from({length:nout},()=>new Float64Array(4*nb));for(let c=0;c<4;c++)for(let k=0;k<6;k++){let V=Array.from({length:4},zero);V[c][k]=1;const value=n?tangentBoundary(W,V,n,kind,opt):tangent(W,V,opt).R;value.forEach((v,i)=>{if(v)for(let j=0;j<nb;j++)rows[i][c*nb+j]+=v*b[j][k];});}return rows;}
const heating=x=>Math.sin(.7*x[0]+.5*x[1])**2;
function primalSystem(coeff,opt,withMatrix=true){const d=domain(opt.quadrature,opt.boundary),rows=[],rhs=[],residual=[];function push(a,r,w){r.forEach((v,i)=>{residual.push(v*w);rhs.push(-v*w);if(a)rows.push(Float64Array.from(a[i],v=>v*w));});}for(const q of d.volume){const W=field(coeff,q.x,opt.degree),r=primal(W,opt),source=primal(family(q.x,opt),opt);if(opt.parameter==='heating')r[3]-=(opt.amplitude||0)*heating(q.x);push(withMatrix?tangentRows(W,q.x,opt.degree,opt):null,r.map((v,i)=>v-source[i]),Math.sqrt(q.w));}for(const e of d.edges){const W=field(coeff,e.x,opt.degree),r=primalBoundary(W,e.n,e.kind,opt),b=primalBoundary(family(e.x,opt),e.n,e.kind,opt);push(withMatrix?tangentRows(W,e.x,opt.degree,opt,e.n,e.kind):null,r.map((v,i)=>v-b[i]),Math.sqrt(e.w*opt.boundaryWeight));}return {rows,rhs,residual,norm:Math.hypot(...residual)};}
function initialCoefficients(opt){const d=domain(opt.quadrature,opt.boundary),rows=d.volume.map(q=>basis(q.x,opt.degree).map(v=>v[0]*Math.sqrt(q.w))),F=qrFactor(rows);return [0,1,2,3].flatMap(c=>qrSolve(F,d.volume.map(q=>family(q.x,opt)[c][0]*Math.sqrt(q.w))));}
function solvePrimal(options={}){const opt={...defaults,...options};validate(opt);const history=[];let coeff=options.initial?options.initial.slice():initialCoefficients(opt),converged=false,reason='iteration limit';for(let it=0;it<15;it++){const sys=primalSystem(coeff,opt),step=qrSolve(qrFactor(sys.rows),sys.rhs),sn=Math.hypot(...step);history.push({iteration:it,residual:sys.norm,step:sn});if(sn<2e-10){converged=true;reason='least-squares stationary';break;}let accepted=false;for(let k=0;k<14;k++){const a=2**-k,trial=coeff.map((v,i)=>v+a*step[i]),valid=domain(2,opt.boundary).volume.every(q=>{const w=field(trial,q.x,opt.degree);return w[0][0]>.1&&w[3][0]>.1;});if(!valid)continue;let ss;try{ss=primalSystem(trial,opt,false);}catch(e){continue;}if(ss.norm<sys.norm||sn*a<1e-11){coeff=trial;accepted=true;break;}}if(!accepted){reason='line search stalled';break;}}
const sol={opt,coeff,history,converged,reason,evaluate:x=>field(coeff,x,opt.degree)},d=domain(opt.quadrature+2,opt.boundary);sol.objective=objective(sol.evaluate,opt);let err=0,den=0;for(const q of d.volume){const w=sol.evaluate(q.x),e=family(q.x,opt);w.forEach((v,i)=>{err+=q.w*(v[0]-e[i][0])**2;den+=q.w*e[i][0]**2;});}sol.referenceError=Math.sqrt(err/den);sol.residual=primalSystem(coeff,{...opt,quadrature:opt.quadrature+2},false).norm;return sol;}
function continuousGradient(solution){const {opt}=solution,d=domain(opt.quadrature+2,opt.boundary);let volume=0,boundaryTerm=0;for(const q of d.volume){const L=solution.evaluate(q.x),W=reference(q.x,opt),Sa=opt.parameter==='family'?tangent(W,variation(q.x,opt),opt).R:[0,0,0,heating(q.x)];volume-=q.w*L.reduce((s,v,i)=>s+v[0]*Sa[i],0);}if(opt.parameter==='family')for(const e of d.edges){const W=reference(e.x,opt),V=variation(e.x,opt),L=solution.evaluate(e.x);if(e.kind==='inflow'){const h=boundaryCoefficients(W,L,e.n,opt);boundaryTerm+=e.w*h.reduce((s,v,i)=>s+v*V[i][0],0);}else if(e.kind==='outflow'){const data=tangentBoundary(W,V,e.n,e.kind,opt);boundaryTerm+=e.w*((L[1][0]+L[3][0]*W[1][0])*data[0]+(L[2][0]+L[3][0]*W[2][0])*data[1]+L[3][0]*data[2]);}/* Family fixes wall velocity, temperature and heat flux: no wall-data term. */}return {volume,boundary:boundaryTerm,total:volume+boundaryTerm};}
function gradientStudy(options={}){const opt={...defaults,...options},base=solvePrimal(opt);if(!base.converged)throw Error('Independent primal solve failed: '+base.reason);const a=solve({...opt,base:base.evaluate}),terms=continuousGradient(a),gradient=terms.total,h=options.step||1e-4,plus=solvePrimal({...opt,amplitude:h,initial:base.coeff}),minus=solvePrimal({...opt,amplitude:-h,initial:base.coeff});if(!plus.converged||!minus.converged)throw Error('Independent perturbed primal solve failed');const fd=(plus.objective-minus.objective)/(2*h),analyticFamily=opt.parameter==='family'?(objective(x=>family(x,{...opt,amplitude:h}),opt)-objective(x=>family(x,{...opt,amplitude:-h}),opt))/(2*h):null;return {base,adjoint:a,gradient,terms,fd,analyticFamily,step:h,relativeDifference:Math.abs(gradient-fd)/Math.max(1e-14,Math.abs(fd)),plus,minus};}
const api={jets,coords,exactBase,exactAdjoint,physical,primal,tangent,primalBoundary,tangentBoundary,adjoint,boundary,domain,basis,field,qrFactor,qrSolve,operatorRows,assemble,measure,solve,tangentRows,initialCoefficients,primalSystem,solvePrimal,gradientStudy,heating,reference,variation,family,continuousGradient,boundaryCoefficients,boundaryRhs,adjointRhs,objective};if(typeof module!=='undefined')module.exports=api;else root.NS2ContinuousBVP=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: continuous_fem.js
/* Independent mixed P1/P2 Galerkin discretization of the continuous adjoint.
Assemble the weak physical-flux variation, never the main FV Jacobian. */
(function(root){'use strict';const B=typeof module!=='undefined'?require('./continuous_bvp'):root.NS2ContinuousBVP,G=typeof module!=='undefined'?require('./geometry'):root.NS2Geometry,C=typeof module!=='undefined'?require('./continuous_check'):root.NS2ContinuousCheck,Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath;
const defaults={level:0,variable:true,mu:.2,Pr:.72,boundary:'open',manufactured:false,cip:.02,quadrature:4,tolerance:1e-10};
function validate(o){if(!Number.isInteger(o.level)||o.level<0||o.level>3)throw Error('Continuous FEM level must be 0..3');if(o.boundary!=='open')throw Error('Local weak tracking reference currently uses the open inner boundary');if(!(o.mu>0&&o.Pr>0&&o.cip>0&&o.cip<=.2))throw Error('Invalid positive transport / CIP setting');if(!Number.isInteger(o.quadrature)||o.quadrature<3||o.quadrature>6)throw Error('Continuous FEM quadrature must be 3..6');}
function mesh(level,wall,grid){const raw=grid||G.mesh(level),X=raw.X.map(x=>x.slice()),nv=X.length,T=raw.C.flatMap(c=>c.length===3?[c]:[[c[0],c[1],c[2]],[c[0],c[2],c[3]]]),map=new Map(),edges=[];for(let k=0;k<T.length;k++){const tri=T[k];for(let j=0;j<3;j++){const a=tri[j],b=tri[(j+1)%3],key=[a,b].sort((a,b)=>a-b).join(',');if(!map.has(key)){map.set(key,edges.length);edges.push({a,b,L:k,R:-1,mid:X.length});X.push(X[a].map((v,i)=>(v+X[b][i])/2));}else edges[map.get(key)].R=k;}}
const tris=T.map(v=>{const [a,b,c]=v.map(i=>X[i]),det=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]),grad=[[b[1]-c[1],c[0]-b[0]],[c[1]-a[1],a[0]-c[0]],[a[1]-b[1],b[0]-a[0]]].map(r=>r.map(x=>x/det));return {v,ids:[...v,...v.map((a,k)=>edges[map.get([a,v[(k+1)%3]].sort((a,b)=>a-b).join(','))].mid)],det,grad,h:Math.sqrt(det),a};});
for(const e of edges){const a=X[e.a],b=X[e.b];e.len=Math.hypot(b[0]-a[0],b[1]-a[1]);e.n=[(b[1]-a[1])/e.len,-(b[0]-a[0])/e.len];if(e.R<0){e.tag=Math.hypot(...a)<1.1&&Math.hypot(...b)<1.1?'wall':'far';e.kind=e.tag==='wall'&&wall!=='open'?wall:e.n[0]*.22+e.n[1]*.055<0?'inflow':'outflow';}}
const dofs=X.map((_,i)=>Array(4).fill(-1));let nd=0;dofs.forEach((d,i)=>{for(let c=i<nv?0:1;c<4;c++)d[c]=nd++;});return {X,nv,tris,edges,dofs,nd,raw};}
function shapes(tri,x){const l=tri.grad.map((g,i)=>(i===0?1:0)+g[0]*(x[0]-tri.a[0])+g[1]*(x[1]-tri.a[1])),lin=l.map((v,i)=>[v,...tri.grad[i],0,0,0]),quad=l.map((v,i)=>{const g=tri.grad[i];return [v*(2*v-1),(4*v-1)*g[0],(4*v-1)*g[1],4*g[0]*g[0],4*g[0]*g[1],4*g[1]*g[1]];});for(let i=0;i<3;i++){const j=(i+1)%3,g=tri.grad[i],h=tri.grad[j];quad.push([4*l[i]*l[j],4*(g[0]*l[j]+l[i]*h[0]),4*(g[1]*l[j]+l[i]*h[1]),8*g[0]*h[0],4*(g[0]*h[1]+g[1]*h[0]),8*g[1]*h[1]]);}return {lin,quad};}
function local(m,k,x){const tr=m.tris[k],s=shapes(tr,x),out=[];for(let j=0;j<6;j++)for(let c=j<3?0:1;c<4;c++)out.push({dof:m.dofs[tr.ids[j]][c],c,jet:c===0?s.lin[j]:s.quad[j]});return out;}
function quadrature(m,k,n){const t=m.tris[k],q=C.gauss(n),[a,b,c]=t.v.map(i=>m.X[i]),out=[];for(const [s,ws]of q)for(const [r,wr]of q)out.push({x:a.map((v,i)=>v+s*(b[i]-v)+(1-s)*r*(c[i]-v)),w:t.det*(1-s)*ws*wr});return out;}
function edgeQuad(m,e,n){const a=m.X[e.a],b=m.X[e.b];return C.gauss(n).map(([s,w])=>({x:a.map((v,i)=>v+s*(b[i]-v)),w:e.len*w}));}
function evalAt(m,coeff,k,x){const out=Array.from({length:4},()=>[0,0,0,0,0,0]);for(const b of local(m,k,x))for(let j=0;j<6;j++)out[b.c][j]+=coeff[b.dof]*b.jet[j];return out;}
function fluxCoefficients(W,opt){const out=[];for(let c=0;c<4;c++){const list=[];for(let d=0;d<3;d++){const V=Array.from({length:4},()=>[0,0,0,0,0,0]);V[c][d]=1;list.push(B.tangent(W,V,opt).F);}out.push(list);}return out;}
function essential(m,opt){const fixed=new Map();for(const e of m.edges.filter(e=>e.R<0))for(const i of [e.a,e.b,e.mid]){const L=B.exactAdjoint(m.X[i]);if(['inflow','isothermal','adiabatic'].includes(e.kind))for(let c=1;c<4;c++)if(c!==3||e.kind!=='adiabatic')fixed.set(m.dofs[i][c],opt.manufactured?L[c][0]:0);}return fixed;}
// Consistent interior penalty: zero for a smooth exact density / alpha.
// Coefficient depends on the prescribed base, so it is fixed in parameter reruns.
function cipEntries(m,opt,put){if(!opt.cip)return;for(const e of m.edges.filter(e=>e.R>=0)){const x=m.X[e.mid],w=B.reference(x,opt),speed=Math.hypot(w[1][0],w[2][0]),scale=opt.cip*e.len**3*speed,jump=new Map();for(const [k,sign]of [[e.L,1],[e.R,-1]]){const tri=m.tris[k];tri.v.forEach((i,j)=>{const dof=m.dofs[i][0],a=sign*(tri.grad[j][0]*e.n[0]+tri.grad[j][1]*e.n[1]);jump.set(dof,(jump.get(dof)||0)+a);});}for(const [i,a]of jump)for(const [j,b]of jump)put(i,j,scale*a*b);}}
function assemble(options={}){const opt={...defaults,...options};validate(opt);const m=mesh(opt.level,opt.boundary,opt.grid),fixed=essential(m,opt),rows=Array.from({length:m.nd},()=>new Map()),rhs=new Float64Array(m.nd),base=opt.base||(x=>B.reference(x,opt)),add=(i,j,v)=>{if(v)rows[i].set(j,(rows[i].get(j)||0)+v);};
for(let k=0;k<m.tris.length;k++)for(const q of quadrature(m,k,opt.quadrature)){const W=base(q.x,k),bs=local(m,k,q.x),fc=fluxCoefficients(W,opt),r=B.adjointRhs(q.x,opt,W);
for(const test of bs){rhs[test.dof]+=q.w*r[test.c]*test.jet[0];for(let j=0;j<bs.length;j++){const trial=bs[j];let v=0;for(let d=0;d<3;d++)v-=test.jet[d]*(trial.jet[1]*fc[test.c][d][0][trial.c]+trial.jet[2]*fc[test.c][d][1][trial.c]);add(test.dof,trial.dof,q.w*v);}}}
for(const e of m.edges.filter(e=>e.R<0))for(const q of edgeQuad(m,e,opt.quadrature+1)){const W=base(q.x,e.L),bs=local(m,e.L,q.x);if(e.kind==='outflow'){const a=B.physical(W,opt),rho=W[0][0],u=W.slice(1,3).map(v=>v[0]),un=u[0]*e.n[0]+u[1]*e.n[1],t=B.primalBoundary(B.family(q.x,opt),e.n,'outflow',opt),E=a.E[0],K=[1,...u,E],hc=Array.from({length:4},()=>Array(4).fill(0));for(let c=0;c<4;c++){hc[0][c]=un*K[c];hc[1][c]=rho*K[c]*e.n[0]+rho*un*((c===1?1:0)+(c===3?u[0]:0))+(c===3?t[0]:0);hc[2][c]=rho*K[c]*e.n[1]+rho*un*((c===2?1:0)+(c===3?u[1]:0))+(c===3?t[1]:0);hc[3][c]=c===3?rho*2.5*un:0;}const target=opt.manufactured?B.boundaryCoefficients(B.reference(q.x,opt),B.exactAdjoint(q.x),e.n,opt):[0,0,0,0];for(const test of bs){rhs[test.dof]+=q.w*test.jet[0]*target[test.c];for(const trial of bs)add(test.dof,trial.dof,q.w*test.jet[0]*trial.jet[0]*hc[test.c][trial.c]);}}else if(e.kind==='adiabatic'&&opt.manufactured){const target=B.boundary(B.reference(q.x,opt),B.exactAdjoint(q.x),e.n,e.kind,opt).B[2];for(const test of bs)if(test.c===3)rhs[test.dof]+=q.w*test.jet[0]*target;}}
cipEntries(m,opt,add);const free=[],map=new Int32Array(m.nd).fill(-1);for(let i=0;i<m.nd;i++)if(!fixed.has(i)){map[i]=free.length;free.push(i);}const reduced=free.map(i=>{const r=new Map();for(const [j,v]of rows[i])if(map[j]>=0)r.set(map[j],v);else rhs[i]-=v*fixed.get(j);return r;});return {opt,m,rows:reduced,rhs:Float64Array.from(free,i=>rhs[i]),free,fixed,map,base};}
function csr(rows){const ptr=[0],col=[],val=[],diag=[];rows.forEach((r,i)=>{for(const [j,v]of [...r].sort((a,b)=>a[0]-b[0])){col.push(j);val.push(v);if(j===i)diag[i]=val.length-1;}ptr.push(val.length);});return {ptr:Int32Array.from(ptr),col:Int32Array.from(col),val:Float64Array.from(val),diag:Int32Array.from(diag),n:rows.length};}
function mv(A,x){const y=new Float64Array(A.n);for(let i=0;i<A.n;i++)for(let k=A.ptr[i];k<A.ptr[i+1];k++)y[i]+=A.val[k]*x[A.col[k]];return y;}
function ilu(A){const {n,ptr,col,diag}=A,v=A.val.slice(),loc=new Int32Array(n).fill(-1);for(let i=0;i<n;i++){for(let k=ptr[i];k<ptr[i+1];k++)loc[col[k]]=k;for(let k=ptr[i];k<diag[i];k++){const j=col[k];v[k]/=v[diag[j]];for(let t=diag[j]+1;t<ptr[j+1];t++){const at=loc[col[t]];if(at>=0)v[at]-=v[k]*v[t];}}if(!Number.isFinite(v[diag[i]])||Math.abs(v[diag[i]])<1e-13)throw Error('Continuous FEM ILU pivot failure '+i);for(let k=ptr[i];k<ptr[i+1];k++)loc[col[k]]=-1;}return b=>{const x=Float64Array.from(b);for(let i=0;i<n;i++)for(let k=ptr[i];k<diag[i];k++)x[i]-=v[k]*x[col[k]];for(let i=n-1;i>=0;i--){for(let k=diag[i]+1;k<ptr[i+1];k++)x[i]-=v[k]*x[col[k]];x[i]/=v[diag[i]];}return Array.from(x);};}
function linearSolve(A,b,opt){const pre=ilu(A),norm=x=>Math.hypot(...x),bn=norm(b),x=new Float64Array(A.n),history=[],target=Math.max(opt.tolerance*bn,1e-13);let iterations=0,converged=false;for(let restart=0;restart<30;restart++){const ax=mv(A,x),r=Array.from(b,(v,i)=>v-ax[i]),rn=norm(r);history.push(rn);if(rn<target){converged=true;break;}const g=Q.gmres(v=>Array.from(mv(A,v)),r,pre,target*.2,Math.min(100,A.n));for(let i=0;i<x.length;i++)x[i]+=g.x[i];iterations+=g.history.length;}const residual=norm(Array.from(mv(A,x),(v,i)=>v-b[i]));if(residual<target)converged=true;return {x,history,iterations,converged,residual,relative:residual/Math.max(bn,1e-30)};}
function solve(options={}){const t=Date.now(),sys=assemble(options),A=csr(sys.rows),ls=linearSolve(A,sys.rhs,sys.opt);if(!ls.converged)throw Error('Continuous FEM linear solve not converged: '+ls.relative);const coeff=new Float64Array(sys.m.nd);sys.free.forEach((i,j)=>coeff[i]=ls.x[j]);for(const [i,v]of sys.fixed)coeff[i]=v;return {...sys,coeff,linear:ls,solveMs:Date.now()-t,evaluate:(x,k)=>evalAt(sys.m,coeff,k,x)};}
function primalFlux(W,opt){const a=B.physical(W,opt),r=W[0][0],u=[W[1][0],W[2][0]],p=a.p[0],H=a.H[0];return [0,1].map(j=>[r*u[j],...u.map((v,i)=>r*v*u[j]+(i===j?p:0)-a.tau[i][j][0]),r*H*u[j]-u.reduce((s,v,i)=>s+v*a.tau[i][j][0],0)-a.kappa[0]*W[3][j+1]]);}
function primalEssential(m,opt){const fixed=new Map();for(const e of m.edges.filter(e=>e.R<0))for(const i of [e.a,e.b,e.mid]){const W=B.family(m.X[i],opt);if(['inflow','isothermal','adiabatic'].includes(e.kind))for(let c=1;c<4;c++)if(c!==3||e.kind!=='adiabatic')fixed.set(m.dofs[i][c],W[c][0]);}return fixed;}
function primalAssemble(m,coeff,opt,matrix=true){const fixed=primalEssential(m,opt),rows=matrix?Array.from({length:m.nd},()=>new Map()):null,R=new Float64Array(m.nd),add=(i,j,v)=>{if(v)rows[i].set(j,(rows[i].get(j)||0)+v);};
for(let k=0;k<m.tris.length;k++)for(const q of quadrature(m,k,opt.quadrature)){const W=evalAt(m,coeff,k,q.x),F=primalFlux(W,opt),source=B.primal(B.family(q.x,opt),opt),bs=local(m,k,q.x),fc=matrix?fluxCoefficients(W,opt):null;if(opt.parameter==='heating')source[3]+=(opt.amplitude||0)*B.heating(q.x);
for(const test of bs){R[test.dof]+=q.w*(-test.jet[1]*F[0][test.c]-test.jet[2]*F[1][test.c]-test.jet[0]*source[test.c]);if(matrix)for(const trial of bs){let v=0;for(let d=0;d<3;d++)v-=trial.jet[d]*(test.jet[1]*fc[trial.c][d][0][test.c]+test.jet[2]*fc[trial.c][d][1][test.c]);add(test.dof,trial.dof,q.w*v);}}}
for(const e of m.edges.filter(e=>e.R<0))for(const q of edgeQuad(m,e,opt.quadrature+1)){const W=evalAt(m,coeff,e.L,q.x),given=B.family(q.x,opt),bs=local(m,e.L,q.x),rho=W[0][0],u=W.slice(1,3).map(v=>v[0]),un=u[0]*e.n[0]+u[1]*e.n[1],a=B.physical(W,opt);if(e.kind==='inflow'){const mass=given[0][0]*(given[1][0]*e.n[0]+given[2][0]*e.n[1]);for(const test of bs)if(test.c===0)R[test.dof]+=q.w*test.jet[0]*mass;}else if(e.kind==='outflow'){const data=B.primalBoundary(given,e.n,e.kind,opt),F=[rho*un,rho*u[0]*un+data[0],rho*u[1]*un+data[1],rho*a.E[0]*un+u[0]*data[0]+u[1]*data[1]+data[2]];for(const test of bs){R[test.dof]+=q.w*test.jet[0]*F[test.c];if(matrix)for(const trial of bs){const c=trial.c,dr=c===0?trial.jet[0]:0,du=[c===1?trial.jet[0]:0,c===2?trial.jet[0]:0],dT=c===3?trial.jet[0]:0,dun=du[0]*e.n[0]+du[1]*e.n[1],dE=2.5*dT+u[0]*du[0]+u[1]*du[1],dF=[dr*un+rho*dun,dr*u[0]*un+rho*du[0]*un+rho*u[0]*dun,dr*u[1]*un+rho*du[1]*un+rho*u[1]*dun,dr*a.E[0]*un+rho*dE*un+rho*a.E[0]*dun+du[0]*data[0]+du[1]*data[1]];add(test.dof,trial.dof,q.w*test.jet[0]*dF[test.c]);}}}else if(e.kind==='adiabatic'){const qn=B.primalBoundary(given,e.n,e.kind,opt)[2];for(const test of bs)if(test.c===3)R[test.dof]+=q.w*test.jet[0]*qn;}}
const free=[],map=new Int32Array(m.nd).fill(-1);for(let i=0;i<m.nd;i++)if(!fixed.has(i)){map[i]=free.length;free.push(i);}cipEntries(m,opt,(i,j,v)=>{R[i]+=v*coeff[j];if(matrix)add(i,j,v);});const reduced=matrix?free.map(i=>{const r=new Map();for(const [j,v]of rows[i])if(map[j]>=0)r.set(map[j],v);return r;}):null;return {rows:reduced,rhs:Float64Array.from(free,i=>-R[i]),free,fixed,map,R,norm:Math.hypot(...free.map(i=>R[i]))};}
function solvePrimal(options={}){const opt={...defaults,parameter:'heating',amplitude:0,...options};validate(opt);const m=mesh(opt.level,opt.boundary,opt.grid),coeff=options.initial?Float64Array.from(options.initial):new Float64Array(m.nd);if(!options.initial)m.X.forEach((x,i)=>{const w=B.reference(x,opt);for(let c=0;c<4;c++)if(m.dofs[i][c]>=0)coeff[m.dofs[i][c]]=w[c][0];});for(const [i,v]of primalEssential(m,opt))coeff[i]=v;const history=[];let converged=false;
for(let it=0;it<12;it++){const sys=primalAssemble(m,coeff,opt),rn=sys.norm;history.push(rn);if(rn<2e-12){converged=true;break;}const ls=linearSolve(csr(sys.rows),sys.rhs,opt);if(!ls.converged)throw Error('Continuous primal linear solve failed');let accepted=false;for(let j=0;j<12;j++){const a=2**-j,trial=coeff.slice();sys.free.forEach((i,k)=>trial[i]+=a*ls.x[k]);try{const next=primalAssemble(m,trial,opt,false).norm;if(next<rn){coeff.set(trial);accepted=true;break;}}catch(e){}}if(!accepted)break;}
let J=0;for(let k=0;k<m.tris.length;k++)for(const q of quadrature(m,k,opt.quadrature)){const T=evalAt(m,coeff,k,q.x)[3][0];J+=.5*q.w*(T-1-.02*Math.cos(q.x[0]))**2;}return {opt,m,coeff,converged,history,objective:J,evaluate:(x,k)=>evalAt(m,coeff,k,x)};}
function gradientStudy(options={}){const opt={...defaults,parameter:'heating',...options},report=options.progress||(()=>{});if(opt.manufactured)throw Error('Tracking gradient requires the actual tracking objective');if(opt.parameter!=='heating'||!(Number.isFinite(options.step??1e-4)&&(options.step??1e-4)>0)||(options.amplitude||0)!==0)throw Error('Tracking gradient requires heating about zero and a positive finite step');report('primal');const base=solvePrimal(opt);if(!base.converged)throw Error('Independent FEM primal did not converge');report('adjoint');const adj=solve({...opt,base:base.evaluate}),h=options.step??1e-4;report('plus');const plus=solvePrimal({...opt,amplitude:h,initial:base.coeff});report('minus');const minus=solvePrimal({...opt,amplitude:-h,initial:base.coeff});if(!plus.converged||!minus.converged)throw Error('Independent FEM perturbed primal did not converge');let gradient=0;for(let k=0;k<adj.m.tris.length;k++)for(const q of quadrature(adj.m,k,opt.quadrature))gradient-=q.w*adj.evaluate(q.x,k)[3][0]*B.heating(q.x);const fd=(plus.objective-minus.objective)/(2*h);return {base,adj,plus,minus,gradient,fd,relativeDifference:Math.abs(gradient-fd)/Math.max(Math.abs(fd),1e-14),step:h};}
function diagnostics(sol){const {m,opt}=sol,indicator=new Float64Array(m.tris.length),volume=[0,0,0,0];let boundary=0,jump=0,gradient=0,norm=0,exactError=0,exactNorm=0;for(let k=0;k<m.tris.length;k++)for(const q of quadrature(m,k,opt.quadrature+1)){const W=sol.base(q.x,k),L=sol.evaluate(q.x,k),z=B.adjoint(W,L,opt),rhs=B.adjointRhs(q.x,opt,W);z.E.forEach((v,i)=>{const rr=q.w*(v-rhs[i])**2;volume[i]+=rr;indicator[k]+=m.tris[k].h**2*rr;});gradient-=q.w*L[3][0]*B.heating(q.x);for(let c=0;c<4;c++){norm+=q.w*L[c][0]**2;if(opt.manufactured){const exact=B.exactAdjoint(q.x)[c][0];exactError+=q.w*(L[c][0]-exact)**2;exactNorm+=q.w*exact**2;}}}
for(const e of m.edges)for(const q of edgeQuad(m,e,opt.quadrature+1)){const L=sol.evaluate(q.x,e.L),W=sol.base(q.x,e.L);if(e.R>=0){const R=sol.evaluate(q.x,e.R),zl=B.adjoint(W,L,opt),zr=B.adjoint(sol.base(q.x,e.R),R,opt),d=[...zl.Z.map((r,i)=>(r[0]-zr.Z[i][0])*e.n[0]+(r[1]-zr.Z[i][1])*e.n[1]),(zl.heat[0]-zr.heat[0])*e.n[0]+(zl.heat[1]-zr.heat[1])*e.n[1]],v=e.len*q.w*d.reduce((s,x)=>s+x*x,0);indicator[e.L]+=.5*v;indicator[e.R]+=.5*v;jump+=v;}else{const a=B.boundary(W,L,e.n,e.kind,opt).B,b=B.boundaryRhs({...e,x:q.x},W,opt),v=e.len*q.w*a.reduce((s,x,i)=>s+(x-b[i])**2,0);indicator[e.L]+=v;boundary+=v;}}
return {indicator:Array.from(indicator),volume:volume.map(Math.sqrt),brokenPdeL2:Math.sqrt(volume.reduce((a,b)=>a+b,0)),boundaryIndicator:Math.sqrt(boundary),jumpIndicator:Math.sqrt(jump),estimator:Math.sqrt(indicator.reduce((a,b)=>a+b,0)),gradient,fieldNorm:Math.sqrt(norm),relativeError:opt.manufactured?Math.sqrt(exactError/exactNorm):null};}
function refine(m,marked){const X=m.X.slice(0,m.nv).map(x=>x.slice()),cut=new Set();for(const k of marked){const v=m.tris[k].v;for(let i=0;i<3;i++)cut.add([v[i],v[(i+1)%3]].sort((a,b)=>a-b).join(','));}const mids=new Map();for(const e of m.edges){const key=[e.a,e.b].sort((a,b)=>a-b).join(',');if(cut.has(key)){mids.set(key,X.length);X.push(m.X[e.mid].slice());}}const C=[],parent=[];m.tris.forEach((tr,k)=>{let v=tr.v,mid=v.map((a,i)=>mids.get([a,v[(i+1)%3]].sort((a,b)=>a-b).join(','))),count=mid.filter(x=>x!==undefined).length,children;if(!count)children=[v.slice()];else if(count===3){const [a,b,c]=v,[ab,bc,ca]=mid;children=[[a,ab,ca],[ab,b,bc],[ca,bc,c],[ab,bc,ca]];}else{let i=count===1?mid.findIndex(x=>x!==undefined):mid.findIndex((x,i)=>x!==undefined&&mid[(i+1)%3]!==undefined);const [a,b,c]=[v[i],v[(i+1)%3],v[(i+2)%3]],ab=mid[i],bc=mid[(i+1)%3];children=count===1?[[a,ab,c],[ab,b,c]]:[[b,bc,ab],[a,ab,c],[ab,bc,c]];}for(const tri of children){C.push(tri);parent.push(k);}});return {X,C,parent};}
function adaptive(options={},progress=()=>{}){const opt={...defaults,level:1,cycles:5,bulk:.45,...options};if(!Number.isInteger(opt.cycles)||opt.cycles<0||opt.cycles>10||!(opt.bulk>0&&opt.bulk<1))throw Error('Invalid adaptive controls');let grid=opt.grid,previous=null,records=[],sol;for(let cycle=0;cycle<=opt.cycles;cycle++){sol=solve({...opt,grid});const d=diagnostics(sol);let difference=null;if(previous){let e=0;for(let k=0;k<sol.m.tris.length;k++)for(const q of quadrature(sol.m,k,3)){const a=sol.evaluate(q.x,k),b=previous.evaluate(q.x,grid.parent[k]);for(let c=0;c<4;c++)e+=q.w*(a[c][0]-b[c][0])**2;}difference=Math.sqrt(e)/d.fieldNorm;}const r={cycle,vertices:sol.m.nv,triangles:sol.m.tris.length,unknowns:sol.free.length,gradient:d.gradient,estimator:d.estimator,relativeFieldChange:difference,gradientChange:records.length?Math.abs(d.gradient-records.at(-1).gradient)/Math.max(Math.abs(d.gradient),1e-14):null,linearResidual:sol.linear.relative,iterations:sol.linear.iterations};records.push(r);progress(r,sol,d);if(cycle===opt.cycles)break;const rank=d.indicator.map((v,k)=>[v,k]).sort((a,b)=>b[0]-a[0]),sum=d.indicator.reduce((a,b)=>a+b,0),marked=[];let done=0;for(const [v,k]of rank){marked.push(k);done+=v;if(done>=opt.bulk*sum)break;}grid=refine(sol.m,marked);if(grid.X.length>4500)break;previous=sol;}return {solution:sol,records,grid:sol.m.raw,diagnostics:diagnostics(sol)};}
const api={mesh,shapes,local,quadrature,edgeQuad,evalAt,fluxCoefficients,essential,assemble,csr,mv,ilu,linearSolve,solve,diagnostics,refine,adaptive,cipEntries,primalFlux,primalAssemble,solvePrimal,gradientStudy};if(typeof module!=='undefined')module.exports=api;else root.NS2ContinuousFEM=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: comparison_gmres.js
/* Restart-cycle kernel without storing every iterate: large-vector refinement
needs the Arnoldi bases, not the lesson player's full iterate history. */
(function(root){'use strict';
function cycle(A,b,P,tol,max){const n=b.length,dot=(a,b)=>{let s=0;for(let i=0;i<n;i++)s+=a[i]*b[i];return s;},norm=a=>Math.sqrt(dot(a,a)),beta=norm(b),x=new Float64Array(n);if(beta<=tol)return {x,iterations:0,residual:beta};const V=[Float64Array.from(b,v=>v/beta)],Z=[],H=[],cs=new Float64Array(max),sn=new Float64Array(max),g=new Float64Array(max+1);g[0]=beta;let m=0;for(let j=0;j<max;j++){const z=Float64Array.from(P(V[j]));Z.push(z);const w=Float64Array.from(A(z)),h=new Float64Array(j+2);for(let pass=0;pass<2;pass++)for(let i=0;i<=j;i++){const a=dot(V[i],w);h[i]+=a;for(let k=0;k<n;k++)w[k]-=a*V[i][k];}const next=norm(w);h[j+1]=next;if(next>1e-16){for(let k=0;k<n;k++)w[k]/=next;V.push(w);}for(let i=0;i<j;i++){const a=h[i],b=h[i+1];h[i]=cs[i]*a+sn[i]*b;h[i+1]=-sn[i]*a+cs[i]*b;}const rho=Math.hypot(h[j],h[j+1]);if(!(rho>0))throw Error('GMRES Arnoldi breakdown');cs[j]=h[j]/rho;sn[j]=h[j+1]/rho;h[j]=rho;h[j+1]=0;H.push(h);g[j+1]=-sn[j]*g[j];g[j]*=cs[j];m=j+1;if(Math.abs(g[j+1])<tol||next<=1e-16)break;}const y=new Float64Array(m);for(let i=m-1;i>=0;i--){let a=g[i];for(let j=i+1;j<m;j++)a-=H[j][i]*y[j];y[i]=a/H[i][i];}for(let i=0;i<m;i++)for(let k=0;k<n;k++)x[k]+=Z[i][k]*y[i];const ax=A(x),r=Float64Array.from(b,(v,i)=>v-ax[i]);return {x,iterations:m,residual:norm(r)};}
const api={cycle};if(typeof module!=='undefined')module.exports=api;else root.NS2ComparisonGMRES=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: comparison.js
/* Cross-discretization study. Same FV residual as the lesson; sparse exact
assembly is used only to make refinement affordable, not as a new scheme. */
(function(root){'use strict';
const C=typeof module!=='undefined'?require('./core'):root.NS2D,F=typeof module!=='undefined'?require('./continuous_fem'):root.NS2ContinuousFEM,K=typeof module!=='undefined'?require('./comparison_gmres'):root.NS2ComparisonGMRES;
function pattern(m){const st=m.top.stencil.map((s,i)=>new Set([i,...s])),rows=st.map(s=>new Set(s));for(const f of m.top.faces)if(f.R>=0){for(const j of st[f.R])rows[f.L].add(j);for(const j of st[f.L])rows[f.R].add(j);}const conflicts=Array.from({length:m.nc},()=>new Set());for(const r of rows)for(const i of r)for(const j of r)if(i!==j)conflicts[i].add(j);const colors=new Int32Array(m.nc).fill(-1);for(const i of [...colors.keys()].sort((a,b)=>conflicts[b].size-conflicts[a].size||a-b)){const used=new Set([...conflicts[i]].map(j=>colors[j]));let c=0;while(used.has(c))c++;colors[i]=c;}return {rows:rows.map(s=>[...s].sort((a,b)=>a-b)),colors,count:Math.max(...colors)+1};}
function linearize(m,U,p=m.params){const t=C.tape(),u=U.map(t.op.input),pp=p.map(t.op.input),out=m.evaluateWith(t.op,u,pp,m.order,p);return {R:out.R.map(i=>t.val[i]),J:t.val[out.J],jvp:(v,dp=Array(m.np).fill(0))=>{if(dp.slice(6).some(x=>x))throw Error('Comparison tape holds geometry fixed');const d=t.forward([...v,...dp]);return {R:out.R.map(i=>d[i]),J:d[out.J]};},vjp:(w,j=0)=>{const d=t.reverse([...out.R,out.J],[...w,j]);return {U:d.slice(0,m.nd),p:d.slice(m.nd)};}};}
function assemble(m,L,pat){const rows=Array.from({length:m.nd},()=>new Map()),v=Array(m.nd).fill(0);for(let color=0;color<pat.count;color++)for(let c=0;c<4;c++){v.fill(0);for(let j=0;j<m.nc;j++)if(pat.colors[j]===color)v[4*j+c]=1;const a=L.jvp(v).R;for(let i=0;i<m.nc;i++){const j=pat.rows[i].find(j=>pat.colors[j]===color);if(j===undefined)continue;for(let r=0;r<4;r++)if(a[4*i+r]!==0||4*i+r===4*j+c)rows[4*i+r].set(4*j+c,a[4*i+r]);}}return rows;}
function transpose(rows){const out=rows.map(()=>new Map());rows.forEach((r,i)=>r.forEach((v,j)=>out[j].set(i,v)));return out;}
const norm=x=>Math.hypot(...x);
function linearSolve(A,b,tolerance=1e-11,vectors=1000){if(!Number.isInteger(vectors)||vectors<1||vectors>1500)throw Error('Invalid GMRES subspace size');const pre=F.ilu(A),x=new Float64Array(A.n),bn=norm(b),target=Math.max(tolerance*bn,1e-13),history=[];let iterations=0;for(let cycle=0;cycle<20;cycle++){const ax=F.mv(A,x),r=Array.from(b,(v,i)=>v-ax[i]),rn=norm(r);history.push(rn);if(rn<target)return {x,residual:rn,relative:rn/Math.max(bn,1e-30),iterations,history,converged:true};const g=K.cycle(v=>F.mv(A,v),r,pre,target*.2,Math.min(A.n,vectors));for(let i=0;i<A.n;i++)x[i]+=g.x[i];iterations+=g.iterations;}const residual=norm(Array.from(F.mv(A,x),(v,i)=>v-b[i]));return {x,residual,relative:residual/Math.max(bn,1e-30),iterations,history,converged:residual<target};}
function solve(m,{p=m.params,start,pat=pattern(m),tolerance=2e-11,vectors=1000,progress=()=>{}}={}){let U=start?Array.from(start):m.initial(p),history=[],linearHistory=[];for(let it=0;it<30;it++){const L=linearize(m,U,p),rn=norm(L.R);history.push(rn);progress({stage:'primal',iteration:it,residual:rn});if(rn<tolerance){const d=m.diagnostics(U,p);if(!d.supported)throw Error('Comparison outside supported boundary branch');return {U,J:L.J,history,pat,linearHistory};}const A=F.csr(assemble(m,L,pat)),ls=linearSolve(A,L.R.map(v=>-v),Math.min(1e-8,Math.max(1e-11,rn*1e-3)),vectors);linearHistory.push({iterations:ls.iterations,residual:ls.residual,relative:ls.relative});if(!ls.converged)throw Error('Comparison Newton linear residual '+ls.relative);let step=1,accepted=false;while(step>1e-7){const V=U.map((v,i)=>v+step*ls.x[i]);if(m.valid(V,p)){const e=m.evaluate(V,p);if(norm(e.R)<(1-1e-4*step)*rn&&e.local.every(f=>f.samples.every(s=>[s.UL,s.UR,s.viscU].every(u=>{const q=m.physics.prim(u);return q.rho>.1&&q.T>.2;})))){U=V;accepted=true;break;}}step*=.5;}if(!accepted)throw Error('Comparison Newton line search failed '+rn);}throw Error('Comparison nonlinear iterations exhausted');}
function discrete(options={}){const {level=0,node=false,order=2,variable=true,fd=false,step=1e-4,vectors=1000,progress=()=>{}}=options;if(!Number.isInteger(level)||level<0||level>4||![1,2].includes(order))throw Error('Unsupported comparison controls');if(![100,300,500,1000,1500].includes(vectors))throw Error('Unsupported GMRES vector count');if(fd&&(!(step>0)||!Number.isFinite(step)))throw Error('FD step must be finite and positive');const started=Date.now(),m=C.model({node,order,level,variable,mu:.2,Pr:.72,mode:'mms',objective:'tracking'}),s=solve(m,{progress,vectors}),L=linearize(m,s.U),rows=assemble(m,L,s.pat),rhs=L.vjp(Array(m.nd).fill(0),1).U.map(v=>-v);progress({stage:'adjoint',level,vectors:Math.min(m.nd,vectors)});const a=linearSolve(F.csr(transpose(rows)),rhs,1e-11,vectors);if(!a.converged)throw Error('Comparison adjoint residual '+a.relative);const psi=Array.from(a.x),g=L.vjp(psi,1).p[5],trueResidual=norm(L.vjp(psi).U.map((v,i)=>v-rhs[i]));if(trueResidual>1e-9)throw Error('Comparison exact transpose residual '+trueResidual);const g0=m.geometry(m.params),exact=m.initial(),den=exact.reduce((sum,v,i)=>sum+g0.vol[Math.floor(i/4)]*v*v,0),error=Math.sqrt(s.U.reduce((sum,v,i)=>sum+g0.vol[Math.floor(i/4)]*(v-exact[i])**2,0)/den),record={node,order,level,variable,vectors:Math.min(m.nd,vectors),adjointIterations:a.iterations,primalLinear:s.linearHistory,cells:m.nc,dofs:m.nd,h:Math.max(...g0.vol.map(Math.sqrt)),J:s.J,gradient:g,primalResidual:s.history.at(-1),adjointResidual:trueResidual,primalError:error,colors:s.pat.count,ms:Date.now()-started};if(fd){const p=m.params.slice(),q=p.slice();p[5]+=step;q[5]-=step;const a=solve(m,{p,start:s.U,pat:s.pat,vectors}),b=solve(m,{p:q,start:s.U,pat:s.pat,vectors});record.fd=(a.J-b.J)/(2*step);record.fdRelative=Math.abs(record.fd-g)/Math.max(Math.abs(g),1e-14);record.step=step;}return record;}
const api={linearSolve,pattern,linearize,assemble,transpose,solve,discrete};if(typeof module!=='undefined')module.exports=api;else root.NS2Comparison=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: ../ns1d/core.js
/* Plane 1D laminar compressible Navier–Stokes. R = Fc_right-Fc_left-Fv_right+Fv_left-S. */
(function(root){'use strict';
const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath;
const N=typeof module!=='undefined'?require('../experiments/numerics'):root.EduNumerics;
const {norm,dot,real,complex,tape,gmres}=Q,{zeros,assemble,transpose,factor,solve:lu,mv}=N;
const cp=3.5,cv=2.5,mass=.35;
function physics(o,variable){const {c,add,sub,mul,div,pow}=o;
const prim=U=>{const rho=U[0],u=div(U[1],rho),T=div(sub(div(U[2],rho),mul(c(.5),mul(u,u))),c(cv));return {rho,u,T,p:mul(rho,T)};};
const cons=(rho,u,T)=>[rho,mul(rho,u),mul(rho,add(mul(c(cv),T),mul(c(.5),mul(u,u))))];
const transport=(T,b)=>{const mu=variable?mul(b[2],div(mul(c(1.4),pow(T,1.5)),add(T,c(.4)))):b[2];return {mu,kappa:div(mul(c(cp),mu),b[3])};};
const flux=U=>{const q=prim(U);return [U[1],add(mul(U[1],q.u),q.p),mul(q.u,add(U[2],q.p))];};
return {prim,cons,transport,flux};
}
function exact(x){const u=mass+.03*Math.sin(Math.PI*x)+.01*Math.sin(2*Math.PI*x),T=1+.05*Math.sin(Math.PI*x)+.02*Math.sin(2*Math.PI*x);return [mass/u,mass,(mass/u)*(cv*T+.5*u*u)];}
function manufactured(x,mu0,Pr,variable){const s=Math.sin(Math.PI*x),c=Math.cos(Math.PI*x),u=mass+.03*s+.01*Math.sin(2*Math.PI*x),T=1+.05*s+.02*Math.sin(2*Math.PI*x),ux=.03*Math.PI*c+.02*Math.PI*Math.cos(2*Math.PI*x),tx=.05*Math.PI*c+.04*Math.PI*Math.cos(2*Math.PI*x),uxx=-.03*Math.PI**2*s-.04*Math.PI**2*Math.sin(2*Math.PI*x),txx=-.05*Math.PI**2*s-.08*Math.PI**2*Math.sin(2*Math.PI*x);
const mu=variable?mu0*1.4*T**1.5/(T+.4):mu0,mut=variable?mu*(1.5/T-1/(T+.4)):0,k=cp*mu/Pr,kt=cp*mut/Pr,tau=4/3*mu*ux,taux=4/3*(mut*tx*ux+mu*uxx);
return [0,mass*ux+mass*(tx/u-T*ux/u**2)-taux,mass*(cp*tx+u*ux)-ux*tau-u*taux-kt*tx*tx-k*txx];}
function model({node=false,n=12,order=2,variable=false,mode='heat',objective='tracking',mu=.15,Pr=.72}={}){
if(n<4)throw Error('At least four intervals required');const h=1/n,m=node?n+1:n,nd=3*m,x=Array.from({length:m},(_,i)=>(i+(node?0:.5))*h),fx=node?[0,...Array.from({length:n},(_,i)=>(i+.5)*h),1]:Array.from({length:n+1},(_,i)=>i*h),vol=x.map((_,i)=>fx[i+1]-fx[i]);
const params=[mode==='heat'?.03:0,0,mu,Pr,1,1],names=['a₀','a₁','μ_ref','Pr','T_in',mode==='heat'?'p_b':'T_out'];
function source(o,xx,b){const {c,add,mul}=o,base=mode==='mms'?manufactured(xx,mu,Pr,variable):[0,0,0];return [c(0),c(base[1]),add(c(base[2]),add(mul(b[0],c(Math.sin(Math.PI*xx)**2)),mul(b[1],c(Math.sin(2*Math.PI*xx)**2))))];}
function weights(i,xx,q=order){if(q===1)return [[i,1]];const d=(xx-x[i])/h;if(i===0)return [[0,1-d],[1,d]];if(i===m-1)return [[i,1+d],[i-1,-d]];return [[i,1],[i-1,-d/2],[i+1,d/2]];}
function evaluate(o,U,b=params,q=order){const {c,add,sub,mul,div,pow}=o,ph=physics(o,variable),R=Array.from({length:nd},()=>c(0)),local=[],sources=[],qstates=Array.from({length:m},(_,i)=>ph.prim(U.slice(3*i,3*i+3)));
const sum=a=>a.reduce((s,v)=>add(s,v),c(0)),lin=(a,w)=>sum(w.map(([i,v])=>mul(c(v),a[i]))),rec=(i,xx)=>[0,1,2].map(k=>lin(U.filter((_,j)=>j%3===k),weights(i,xx,q))),at=(key,i)=>qstates[i][key];
const inlet=ph.cons(c(1),c(mass),b[4]);
for(let f=0;f<=m;f++){const L=f-1,Ri=f,xx=fx[f];let ul,ur,face,ux,tx;
if(f>0&&f<m){ul=rec(L,xx);ur=rec(Ri,xx);face=ph.prim([0,1,2].map(k=>mul(c(.5),add(U[3*L+k],U[3*Ri+k]))));ux=mul(c(1/h),sub(at('u',Ri),at('u',L)));tx=mul(c(1/h),sub(at('T',Ri),at('T',L)));}
else if(node){const i=f===0?0:m-1;ul=ur=U.slice(3*i,3*i+3);face=ph.prim(ul);const w=f===0?[[0,-1.5/h],[1,2/h],[2,-.5/h]]:[[m-1,1.5/h],[m-2,-2/h],[m-3,.5/h]];ux=lin(qstates.map(s=>s.u),w);tx=lin(qstates.map(s=>s.T),w);}
else if(f===0){ul=ur=inlet;face=ph.prim(inlet);ux=mul(c(1/h),sum([mul(c(-8/3),face.u),mul(c(3),at('u',0)),mul(c(-1/3),at('u',1))]));tx=mul(c(1/h),sum([mul(c(-8/3),face.T),mul(c(3),at('T',0)),mul(c(-1/3),at('T',1))]));}
else {const ext=key=>sub(mul(c(1.5),at(key,m-1)),mul(c(.5),at(key,m-2)));const rho=ext('rho'),u=mode==='mms'?c(mass):ext('u'),T=mode==='mms'?b[5]:ext('T');ul=ur=ph.cons(rho,u,T);face=ph.prim(ul);ux=mode==='mms'?mul(c(1/h),sum([mul(c(8/3),u),mul(c(-3),at('u',m-1)),mul(c(1/3),at('u',m-2))])):c(0);tx=mode==='mms'?mul(c(1/h),sum([mul(c(8/3),T),mul(c(-3),at('T',m-1)),mul(c(1/3),at('T',m-2))])):c(0);}
const tr=ph.transport(face.T,b);let tau=mul(c(4/3),mul(tr.mu,ux)),heat=mul(c(-1),mul(tr.kappa,tx));
if(!node&&f===m&&mode==='heat'){tau=sub(face.p,b[5]);ux=div(tau,mul(c(4/3),tr.mu));}
let fc=ph.flux(ul),alpha=c(0);if(f>0&&f<m){const a=ph.prim(ul),bb=ph.prim(ur),fr=ph.flux(ur);alpha=mul(c(.5),sum([a.u,bb.u,pow(mul(c(1.4),a.T),.5),pow(mul(c(1.4),bb.T),.5)]));fc=fc.map((v,k)=>sub(mul(c(.5),add(v,fr[k])),mul(mul(c(.5),alpha),sub(ur[k],ul[k]))));}
const fv=[c(0),tau,sub(mul(face.u,tau),heat)],H=fc.map((v,k)=>sub(v,fv[k]));
H.forEach((v,k)=>{if(L>=0)R[3*L+k]=add(R[3*L+k],v);if(Ri<m)R[3*Ri+k]=sub(R[3*Ri+k],v);});local.push({x:xx,ul,ur,fc,fv,H,ux,tx,tau,heat,mu:tr.mu,kappa:tr.kappa,u:face.u,T:face.T,p:face.p,alpha});
}
let J=c(0);for(let i=0;i<m;i++){const mid=(fx[i]+fx[i+1])/2,half=vol[i]/2,S=[c(0),c(0),c(0)];for(const z of [-1/Math.sqrt(3),1/Math.sqrt(3)]){const xx=mid+half*z,ss=source(o,xx,b);ss.forEach((v,k)=>S[k]=add(S[k],mul(c(half),v)));const T=ph.prim(rec(i,xx)).T,d=sub(T,c(1+.04*Math.sin(Math.PI*xx)));if(objective==='tracking')J=add(J,mul(c(.5*half),mul(d,d)));}S.forEach((v,k)=>R[3*i+k]=sub(R[3*i+k],v));sources.push(S);}
if(objective==='outlet')J=local[m].T;if(objective==='heatflux')J=local[0].heat;
const raw=R.slice(),constraints=[];if(node){const put=(row,v)=>{R[row]=v;constraints.push(row);};put(0,sub(qstates[0].rho,c(1)));put(1,sub(qstates[0].u,c(mass)));put(2,sub(qstates[0].T,b[4]));if(mode==='mms'){put(3*(m-1)+1,sub(qstates[m-1].u,c(mass)));put(3*(m-1)+2,sub(qstates[m-1].T,b[5]));}else{put(3*(m-1)+1,sub(sub(local[m].p,local[m].tau),b[5]));put(3*(m-1)+2,local[m].heat);}}
return {R,J,raw,local,sources,constraints};
}
function linearize(U,b=params,q=order){const t=tape(),u=U.map(t.op.input),p=b.map(t.op.input),out=evaluate(t.op,u,p,q),vals=a=>a.map(i=>t.val[i]);
const extract=(a,d)=>Object.fromEntries(Object.entries(a).map(([k,v])=>[k,k==='x'?v:Array.isArray(v)?v.map(i=>d[i]):d[v]]));
/* Tapenade-style names: x_d is the tangent (dot) of x, x_b its adjoint (bar). jvp(U_d,D_d) returns the tangents
R_d, J_d, ... under the keys R, raw, J, local, sources; vjp(R_b,J_b) returns U_b and D_b under the keys U and p. */
return {R:vals(out.R),J:t.val[out.J],jvp:(U_d,D_d=zeros(b.length))=>{const x_d=t.forward([...U_d,...D_d]);return {R:out.R.map(i=>x_d[i]),raw:out.raw.map(i=>x_d[i]),J:x_d[out.J],local:out.local.map(a=>extract(a,x_d)),sources:out.sources.map(a=>a.map(i=>x_d[i]))};},vjp:(R_b,J_b=0)=>{const x_b=t.reverse([...out.R,out.J],[...R_b,J_b]);return {U:x_b.slice(0,nd),p:x_b.slice(nd)};},constraintVjp:R_b=>t.reverse(out.constraints.map(i=>out.R[i]),out.constraints.map(i=>R_b[i])).slice(0,nd),faceVjp:(f,H_b)=>t.reverse(out.local[f].H,H_b).slice(0,nd)};
}
function precondition(U,b=params){const P=assemble(linearize(U,b,1),nd),blocks=Array.from({length:m},(_,i)=>factor(P.slice(3*i,3*i+3).map(r=>r.slice(3*i,3*i+3))));
// One forward block GS sweep B=(diag(P)+L)^-1; reverse sweep is exactly B^T.
const apply=(r,tr=false)=>{const z=zeros(nd);for(let it=0;it<m;it++){const i=tr?m-1-it:it,rr=r.slice(3*i,3*i+3);for(let j=tr?i+1:0;tr?j<m:j<i;j++)for(let k=0;k<3;k++)for(let l=0;l<3;l++)rr[k]-=(tr?P[3*j+l][3*i+k]:P[3*i+k][3*j+l])*z[3*j+l];const block=tr?factor(transpose(P.slice(3*i,3*i+3).map(r=>r.slice(3*i,3*i+3)))):blocks[i],v=lu(block,rr);v.forEach((a,k)=>z[3*i+k]=a);}return z;};return {P,apply};}
function linearSolve(L,rhs,pre,trans=false,method='gmres',tol=1e-11){const op=v=>trans?L.vjp(v).U:L.jvp(v).R;
if(method==='lu'){const A=assemble(L,nd),x=lu(factor(trans?transpose(A):A),rhs);return {x,history:[norm(rhs),norm(op(x).map((v,i)=>v-rhs[i]))],converged:true,A};}
if(method==='gmres')return gmres(op,rhs,r=>pre.apply(r,trans),tol,nd);
let x=zeros(nd),history=[];for(let it=0;it<1500;it++){const ax=op(x),r=rhs.map((v,i)=>v-ax[i]),rn=norm(r);history.push(rn);if(rn<tol)return {x,history,converged:true};if(!Number.isFinite(rn)||rn>1e8)break;const d=pre.apply(r,trans);x=x.map((v,i)=>v+.65*d[i]);}return {x,history,converged:false};}
function valid(U,b=params){try{const z=evaluate(real,U,b);return z.local.every(f=>[f.ul,f.ur].every(v=>{const s=physics(real,variable).prim(v);return s.rho>.1&&s.T>.3&&s.u>.05&&s.u<.9*Math.sqrt(1.4*s.T);}));}catch{return false;}}
function initial(){return x.flatMap(xx=>mode==='mms'?exact(xx):[1,mass,cv+.5*mass**2]);}
function solve(b=params,start,method='gmres',tol=2e-12){let U=start?start.slice():initial();const history=[],states=[];for(let it=0;it<35;it++){const L=linearize(U,b),rn=norm(L.R);history.push(rn);states.push(U.slice());if(rn<tol)return {U,history,states,converged:true};const pre=precondition(U,b),v=linearSolve(L,L.R.map(x=>-x),pre,false,method,2e-13);if(!v.converged)throw Error('Primal linear solve failed');let step=1,ok=false;while(step>1e-8){const trial=U.map((x,i)=>x+step*v.x[i]);if(valid(trial,b)&&norm(evaluate(real,trial,b).R)<(1-1e-4*step)*rn){U=trial;ok=true;break;}step*=.5;}if(!ok)throw Error('No admissible Newton correction');}throw Error('Primal residual did not converge');}
function sensitivity(sol,b=params,k=0,method='gmres'){const L=linearize(sol.U,b),pre=precondition(sol.U,b),D_d=zeros(b.length);D_d[k]=1;const tangent=linearSolve(L,L.jvp(zeros(nd),D_d).R.map(x=>-x),pre,false,method),adjoint=linearSolve(L,L.vjp(zeros(nd),1).U.map(x=>-x),pre,true,method);if(!tangent.converged||!adjoint.converged)throw Error('Derivative solve did not converge');return {L,pre,tangent,adjoint,gradient:L.vjp(adjoint.x,1).p,directional:L.jvp(tangent.x,D_d).J};}
function complexGradient(sol,b=params,k=0){const h=1e-25,p=b.map((v,i)=>[v,i===k?h:0]),U=sol.U.map(v=>[v,0]),L=linearize(sol.U,b),pre=precondition(sol.U,b);for(let it=0;it<4;it++){const z=evaluate(complex,U,p),r=z.R.map(v=>v[1]/h);if(norm(r)<1e-10)return {gradient:z.J[1]/h,residual:norm(r)};const s=linearSolve(L,r.map(v=>-v),pre,false,'gmres',1e-13);if(!s.converged)throw Error('Complex correction failed');U.forEach((v,i)=>v[1]+=h*s.x[i]);}const z=evaluate(complex,U,p),residual=norm(z.R.map(v=>v[1]/h));if(residual>1e-9)throw Error('Complex residual not converged');return {gradient:z.J[1]/h,residual};}
return {node,n,m,nd,h,x,fx,vol,order,mode,variable,objective,params,names,source,weights,initial,valid,solve,linearize,precondition,linearSolve,sensitivity,complexGradient,evaluate:(U,b=params)=>evaluate(real,U,b),evaluateWith:evaluate,physics:physics(real,variable)};
}
const api={model,physics,exact,manufactured,cp,cv,mass};if(typeof module!=='undefined')module.exports=api;else root.NS1D=api;
})(typeof window!=='undefined'?window:globalThis);
// FILE: ../ns1d/continuous.js
/* Independent spatial ODE BVP and continuous ODE adjoint. No FV residual/Jacobian calls. */
(function(root){'use strict';const Q=typeof module!=='undefined'?require('../q1d/reconstruction/math'):root.QMath,N=typeof module!=='undefined'?require('../experiments/numerics'):root.EduNumerics,C=typeof module!=='undefined'?require('./core'):root.NS1D;
const {real,tape,norm,dot}=Q,{zeros,factor,solve:lu}=N,m=C.mass,cp=C.cp;
const add=(a,b,s=1)=>a.map((v,i)=>v+s*b[i]);
function rk(f,x,y,h){const a=f(x,y),b=f(x+h/2,add(y,a,h/2)),c=f(x+h/2,add(y,b,h/2)),d=f(x+h,add(y,c,h));return y.map((v,i)=>v+h*(a[i]+2*b[i]+2*c[i]+d[i])/6);}
function equations(model,o,x,z,b){const {c,add,sub,mul,div}=o,[u,T,P,H]=z,tr=C.physics(o,model.variable).transport(T,b),tau=sub(add(mul(c(m),u),div(mul(c(m),T),u)),P),ux=div(tau,mul(c(4/3),tr.mu)),ht=mul(c(m),add(mul(c(cp),T),mul(c(.5),mul(u,u)))),heat=add(sub(H,ht),mul(u,tau)),tx=div(mul(c(-1),heat),tr.kappa),S=model.source(o,x,b),d=sub(T,c(1+.04*Math.sin(Math.PI*x))),j=model.objective==='tracking'?mul(c(.5),mul(d,d)):c(0);return {f:[ux,tx,S[1],S[2]],j,heat,tau};}
function boundary(model,o,z,b){const {c,sub,mul}=o;return model.mode==='mms'?[sub(z[0],c(m)),sub(z[1],b[5])]:[sub(sub(z[2],mul(c(m),z[0])),b[5]),equations(model,o,1,z,b).heat];}
function derivatives(model,x,z,b){const t=tape(),zz=z.map(t.op.input),bb=b.map(t.op.input),out=equations(model,t.op,x,zz,bb),ids=[...out.f,out.j],jac=ids.map(i=>t.reverse([i],[1]));return {f:out.f.map(i=>t.val[i]),j:t.val[out.j],A:jac.slice(0,4).map(r=>r.slice(0,4)),B:jac.slice(0,4).map(r=>r.slice(4)),jz:jac[4].slice(0,4),jp:jac[4].slice(4)};}
function endpoint(model,z,b,which){const t=tape(),zz=z.map(t.op.input),bb=b.map(t.op.input),phi=which===0&&model.objective==='heatflux'?equations(model,t.op,0,zz,bb).heat:which===1&&model.objective==='outlet'?zz[1]:t.op.c(0),g=boundary(model,t.op,zz,bb),d=t.reverse([phi],[1]);return {phi:t.val[phi],phiz:d.slice(0,4),phip:d.slice(4),g:g.map(i=>t.val[i]),G:g.map(i=>t.reverse([i],[1]).slice(0,4)),Gp:g.map(i=>t.reverse([i],[1]).slice(4))};}
function integrate(model,b,initial,steps,variations=false){let y=[m,b[4],...initial];if(variations)y.push(...[0,0,0,0,1,0,0,1]);const states=[y.slice(0,4)],f=(x,y)=>{const d=derivatives(model,x,y.slice(0,4),b);return variations?[...d.f,...d.A.flatMap(r=>[0,1].map(k=>r.reduce((s,v,j)=>s+v*y[4+2*j+k],0)))]:d.f;};for(let i=0;i<steps;i++){y=rk(f,i/steps,y,1/steps);if(!y.every(Number.isFinite)||y[0]<=.02||y[1]<=.2||y[0]>.95*Math.sqrt(1.4*y[1]))throw Error('Continuous trajectory outside positive subsonic branch');states.push(y.slice(0,4));}return {states,Y:variations?Array.from({length:4},(_,i)=>y.slice(4+2*i,6+2*i)):null};}
function solve(model,b=model.params,steps=256,warm=null){if(!warm&&model.mode==='heat'&&Math.max(Math.abs(b[0]),Math.abs(b[1]))>.04){const base=b.slice();base[0]=base[1]=0;let r=solve(model,base,steps);for(let k=1;k<=6;k++){const next=b.slice();next[0]*=k/6;next[1]*=k/6;r=solve(model,next,steps,r.states[0].slice(2));}return r;}let guess=[m*m+1,m*(cp*b[4]+.5*m*m)];if(model.mode==='mms'){const z=C.exact(0),q=C.physics(real,model.variable).prim(z),mu=C.physics(real,model.variable).transport(q.T,b);const tau=4/3*mu.mu*.05*Math.PI;guess=[m*m+1-tau,m*(cp+.5*m*m)-m*tau-mu.kappa*.09*Math.PI];}
if(warm)guess=warm.slice();let run,history=[];for(let it=0;it<20;it++){run=integrate(model,b,guess,steps,true);const ep=endpoint(model,run.states.at(-1),b,1),rn=norm(ep.g);history.push(rn);if(rn<2e-12)break;const A=ep.G.map(r=>[0,1].map(k=>r.reduce((s,v,j)=>s+v*run.Y[j][k],0))),d=lu(factor(A),ep.g.map(v=>-v));let step=1,ok=false;while(step>1e-7){try{const trial=add(guess,d,step),r=integrate(model,b,trial,steps),err=norm(endpoint(model,r.states.at(-1),b,1).g);if(err<rn){guess=trial;ok=true;break;}}catch{}step*=.5;}if(!ok)throw Error('Continuous shooting failed');}
if(history.at(-1)>2e-11)throw Error('Continuous boundary residual not converged');
const states=run.states,fs=states.map((z,i)=>equations(model,real,i/steps,z,b).f);
function at(x){const i=Math.min(steps-1,Math.max(0,Math.floor(x*steps))),t=x*steps-i,h=1/steps;return states[i].map((v,k)=>(2*t**3-3*t*t+1)*v+(t**3-2*t*t+t)*h*fs[i][k]+(-2*t**3+3*t*t)*states[i+1][k]+(t**3-t*t)*h*fs[i+1][k]);}
let integral=0;for(let i=0;i<steps;i++){const x=i/steps,h=1/steps;integral+=h/6*(equations(model,real,x,states[i],b).j+4*equations(model,real,x+h/2,at(x+h/2),b).j+equations(model,real,x+h,states[i+1],b).j);}
return {states,at,steps,history,J:integral+endpoint(model,states[0],b,0).phi+endpoint(model,states.at(-1),b,1).phi};}
function adjoint(model,ref,b=model.params){const n=ref.steps,end=endpoint(model,ref.states.at(-1),b,1),start=endpoint(model,ref.states[0],b,0);
const march=(terminal,forcing=true,withGradient=false)=>{let y=[...terminal,...(withGradient?zeros(b.length):[])],values=Array(n+1);values[n]=terminal;const f=(x,y)=>{const d=derivatives(model,x,ref.at(x),b),v=Array.from({length:4},(_,i)=>-d.A.reduce((s,r,j)=>s+r[i]*y[j],0)-(forcing?d.jz[i]:0));if(withGradient)v.push(...b.map((_,i)=>d.jp[i]+d.B.reduce((s,r,j)=>s+r[i]*y[j],0)));return v;};for(let i=n;i>0;i--){y=rk(f,i/n,y,-1/n);values[i-1]=y.slice(0,4);}return {y,values};};
const a=march(end.phiz),r=end.G.map(v=>march(v,false)),rhs=[2,3].map(i=>-start.phiz[i]-a.y[i]),mat=[2,3].map(i=>r.map(v=>v.y[i])),eta=lu(factor(mat),rhs),terminal=end.phiz.map((v,i)=>v+end.G.reduce((s,r,j)=>s+eta[j]*r[i],0)),out=march(terminal,true,true),l0=out.y.slice(0,4),gradient=b.map((_,i)=>start.phip[i]+end.phip[i]-out.y[4+i]+eta.reduce((s,v,j)=>s+v*end.Gp[j][i],0)+(i===4?l0[1]+start.phiz[1]:0));
return {values:out.values,eta,gradient,boundaryResidual:Math.hypot(l0[2]+start.phiz[2],l0[3]+start.phiz[3])};}
const api={solve,adjoint,equations,derivatives,boundary,endpoint,rk};if(typeof module!=='undefined')module.exports=api;else root.NSContinuous=api;
})(typeof window!=='undefined'?window:globalThis);