Page 1 of 1

BETTER_BVR - a better WRA default for BVR combat

Posted: Mon Feb 06, 2023 4:46 am
by musurca
UPDATED 2/7/2023 -- with new model for estimating Decision Range

Hi everyone -- in response to a fairly silly forum thread elsewhere (no link, we don't need to dig that hole any deeper), I wondered aloud whether setting "WRA to NEZ" was the best default to produce lifelike BVR tactics across a range of existing CMO scenarios. My thought was that it might be better to take into account the range of the enemy's weapons instead one's own, in order to estimate a kind of Minimum Abort Range (MAR) so that aircraft might survive more encounters while still being aggressive.

I wrote a Lua script called "BETTER_BVR" as a way of testing that theory (if it's valid), and I'm posting it here in the hopes that more knowledgeable Command-ers might find ways of improving it. The idea is that you can run this script in the editor at the start of any scenario, and the WRAs of all BVR weapons on all sides will be set to a value that takes into account the enemy's most dangerous AA weapon. The result should be much more apparently-reasonable behavior in BVR combat as a starting point for further customization.

Here it is attached -- please chuck your spears at it, so long as they are spears of the constructive variety (okay, I'm mixing metaphors now, but you get the idea).

Code: Select all

--[[
    BETTER_BVR
    by @musurca - 2/7/2023

    This script automatically sets the WRA of each side's BVR weapons
    based on the following rules:
    * If outranged by the enemy's best AA missile, then fire at the weapon's
        maximum range;
    * Otherwise, fire at a Decision Range (DR), which is calculated from the 
        attack geometry by estimating a MAR, then estimating a conservative
        distance at which one can fire, crank, and still escape in the event
        of a head-on aspect encounter.
    * In the unlikely event that the DR is less than 1/3 of our weapon range,
        then just use the No-Escape-Zone (NEZ) of our weapon.

    Unit BVR engagement doctrine is also set to crank-and-drag by default, 
    giving the pilot the option to drag if necessary to survive.

    The WRA of all WVR weapons (determined by the value of RANGE_WVR below) is 
    set to NEZ.
--]]

-- set this to true to print each side's most dangerous BVR weapon & range
local DEBUG_MODE = false

-- maximum range of a missile in nm, below which it's considered WVR
local RANGE_WVR = 12

-- percentage of enemy maximum range used to estimate MAR
local MAR_RANGE_PERCENT = 0.5

-- assumed fov of radar sensor in degrees
local SENSOR_FOV = 50

-- rough scalar to account for drag in average ground speed over time
local DRAG_ESTIMATE = 0.85

-- assumed attack ground speed in knots
local ATTACK_GS = 920

-- assumed maximum missile ground speed in knots
local MISSILE_GS = 2600

-- assumed distance at which ARH missile goes active in nm
local MISSILE_ACTIVE_DIST = 10

-- distance in nm to pad Decision Range to allow room for escape maneuver
local DR_PADDING = 2

-- BVR engagement logic
local BVR_LOGIC = 2 -- crank-and-drag

-- values calculated from defaults above
local CRANK_ASPECT = math.cos(0.5*math.rad(SENSOR_FOV))
local MISSILE_CLOSURE_RATE = DRAG_ESTIMATE*MISSILE_GS/(60*60)
local CLOSURE_RATE = ATTACK_GS/(60*60)

--target types affected by the script
local BVR_TARGET_TYPES = {
    'Air_Contact_Unknown_Type',
    'Aircraft_Unspecified',
    'Aircraft_5th_Generation',
    'Aircraft_4th_Generation',
    'Aircraft_3rd_Generation',
    'Aircraft_Less_Capable'
}

local NON_BVR_TARGET_WRAS = {
    {'Aircraft_High_Perf_Bombers', '75ofmax'},
    {'Aircraft_Medium_Perf_Bombers', 'max'},
    {'Aircraft_Low_Perf_Bombers', 'max'},
    {'Aircraft_High_Perf_Recon_EW', '50ofmax'},
    {'Aircraft_Medium_Perf_Recon_EW', '75ofmax'},
    {'Aircraft_Low_Perf_Recon_EW', 'max'},
    {'Aircraft_AEW', 'max'},
    {'Aircraft_Tanker', 'max'},
    {'Helicopter_Unspecified', '75ofmax'}
}

local sides = VP_GetSides()
local max_range_weapon = {}
local threat_range_max = {}
local wras_adjusted = 0

function get_weapon_range_max_aa(wpn_dbid)
    local db_data = ScenEdit_QueryDB('weapon', wpn_dbid)
    local ranges = db_data.ranges.air
    return ranges.max
end

function set_unit_noncritical_wra(unit, weapon)
    for _, wra_pair in ipairs(NON_BVR_TARGET_WRAS) do
        local wra_type = wra_pair[1]
        local wra_range = wra_pair[2]

        local wra = ScenEdit_GetDoctrineWRA({
            unitname=unit.guid,
            target_type=wra_type,
            weapon_id=weapon.wpn_dbid
        })

        local qtysalvo = '1'
        local shootersalvo = '1'
        local selfdefense = 'max'

        if wra.WRA then
            qtysalvo =  wra.WRA.QTY_SALVO
            shootersalvo = wra.WRA.SHOOTER_SALVO
            selfdefense = wra.WRA.SELF_DEFENCE
        end

        ScenEdit_SetDoctrineWRA(
            {
                unitname=unit.guid, 
                target_type=wra_type, 
                weapon_id=weapon.wpn_dbid
            },
            {
                qtysalvo,
                shootersalvo,
                wra_range,
                selfdefense
            }
        )
    end
end

function get_unit_max_range_sensor_aa(unit)
    local max_range = 0
    for _, comp in ipairs(unit.components) do
        if comp.comp_type == 'Sensor' then
            local db = ScenEdit_QueryDB('sensor', comp.comp_dbid)
            if db.type == 2004 or db.type == 2001 then
                if max_range < db.ranges.max then
                    max_range = db.ranges.max
                end
            end
        end
    end
    return max_range
end

function get_all_side_aircraft(side)
    local units = {}
    for _, unit_stub in ipairs(side.units) do
        local u = ScenEdit_GetUnit({guid=unit_stub.guid})
        if u.type == "Aircraft" then
            table.insert(units, u)
        elseif u.type == "Facility" or u.type == "Ship" then
            if u.embarkedUnits then
                if u.embarkedUnits.Aircraft then
                    local ac_tbl = u.embarkedUnits.Aircraft
                    for i, v in ipairs(ac_tbl) do
                        local ac = ScenEdit_GetUnit({guid=v})
                        table.insert(units, ac)
                    end
                end
            end
        end
    end

    return units
end

-- STEP 1 -- calculate the maximum range of each side's AA missile arsenal
for _, side in ipairs(sides) do
    local max_range = 0
    local dangerous_weapon = ""

    local units = get_all_side_aircraft(side)
    for _, unit in ipairs(units) do
        local max_sensor_range = get_unit_max_range_sensor_aa(unit)

        local loadout = ScenEdit_GetLoadout({
            unitname=unit.guid, 
            LoadoutID=0
        })
        for _, weapon in ipairs(loadout.weapons) do
            -- loop through all missiles
            if weapon.wpn_type == 2001 then 
                --retrieve the missile's maximum AA range from the DB
                local weap_range_max = get_weapon_range_max_aa(weapon.wpn_dbid)
                
                -- weapon limited by sensor range
                if max_sensor_range < weap_range_max then
                    weap_range_max = max_sensor_range
                end
                
                -- compare to current maximum and replace it if it exceeds
                if weap_range_max > max_range then
                    dangerous_weapon = weapon.wpn_name
                    max_range = weap_range_max
                end
            end
        end
    end
    
    max_range_weapon[side.name] = {
        range=max_range, 
        name=dangerous_weapon
    }
end

-- STEP 2 -- calculate the "most dangerous range" for each side, based on their
-- enemy's maximum missile range
for _, side in ipairs(sides) do
    local max_range = 0
    for _, otherside in ipairs(sides) do
        if side.name ~= otherside.name then
            local posture = ScenEdit_GetSidePosture(side.name, otherside.name)
            if posture == 'H' or posture == 'U' then -- if hostile or unfriendly
                if max_range_weapon[otherside.name].range > max_range then
                    max_range = max_range_weapon[otherside.name].range
                end
            end
        end
    end
    threat_range_max[side.name] = max_range
end

-- STEP 3 - estimate a custom MAR for each air unit on each side
-- and set the WRA accordingly
for _, side in ipairs(sides) do
    -- this is the maximum range of our enemy's best AA missile
    local threat_range = threat_range_max[side.name]

    local units = get_all_side_aircraft(side)

    -- now estimate a custom decision range for each air unit in BVR combat
    for _, unit in ipairs(units) do
        -- set our BVR engagement doctrine
        ScenEdit_SetDoctrine(
            {guid=unit.guid}, 
            {bvr_logic=BVR_LOGIC}
        )

        local loadout = ScenEdit_GetLoadout({
            unitname=unit.guid, 
            LoadoutID=0
        })

        local wra_was_adjusted = false
        for _, weapon in ipairs(loadout.weapons) do
            -- loop through all missiles
            if weapon.wpn_type == 2001 then
                local wpn_range_max = get_weapon_range_max_aa(weapon.wpn_dbid)

                -- set the WRAs for non-BVR threats
                set_unit_noncritical_wra(unit, weapon)

                local wra = ScenEdit_GetDoctrineWRA({
                    unitname=unit.guid, 
                    target_type='Aircraft_Unspecified',
                    weapon_id=weapon.wpn_dbid
                })

                if wra.WRA then
                    if wpn_range_max > RANGE_WVR then
                        -- this is a BVR weapon

                        -- Estimate a MAR
                        local mar = MAR_RANGE_PERCENT * threat_range
                        local own_closure_rate = CLOSURE_RATE*CRANK_ASPECT
                        local enemy_closure_rate = CLOSURE_RATE
                        
                        -- Estimate the Decision Range
                        local dist = (mar - MISSILE_ACTIVE_DIST) * (own_closure_rate + enemy_closure_rate) / (MISSILE_CLOSURE_RATE - own_closure_rate)
                        local decision_range = mar + dist + DR_PADDING
                        local wpn_range_nez = MAR_RANGE_PERCENT * wpn_range_max

                        if decision_range > wpn_range_max then
                            -- we're outranged by the enemy's best BVR weapon, so
                            -- it's safest to fire at our weapon's maximum range
                            wra.WRA.FIRING_RANGE = 'Max'
                        elseif decision_range < wpn_range_nez then
                            -- we significantly outrange the enemy, so use NEZ
                            wra.WRA.FIRING_RANGE = 'NEZ'
                        else
                            -- use the Decision Range
                            wra.WRA.FIRING_RANGE = tostring(decision_range)
                        end
                    else
                        -- it's a WVR weapon. if we're already in the merge, 
                        -- use the weapon's no-escape-zone
                        wra.WRA.FIRING_RANGE = 'NEZ'
                    end
                
                    for _, targ in ipairs(BVR_TARGET_TYPES) do
                        ScenEdit_SetDoctrineWRA(
                            {
                                unitname=unit.guid, 
                                target_type=targ, 
                                weapon_id=weapon.wpn_dbid
                            },
                            {
                                wra.WRA.QTY_SALVO,
                                wra.WRA.SHOOTER_SALVO,
                                wra.WRA.FIRING_RANGE,
                                wra.WRA.SELF_DEFENCE
                            }
                        )
                    end

                    wra_was_adjusted = true
                end
            end
        end

        if wra_was_adjusted then
            wras_adjusted = wras_adjusted + 1
        end
    end
end

print("Done! "..tostring(wras_adjusted).." units had their WRAs adjusted.")

if DEBUG_MODE then
    print("")
    for _, side in ipairs(sides) do
        local weap = max_range_weapon[side.name]
        if weap.range > 0 then
            print(side.name.." - "..weap.name.." ("..weap.range.."nm)")
        end
    end
end

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Mon Feb 06, 2023 12:30 pm
by BDukes
Wow! Will check this out today.

Thanks!

Mike

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Mon Feb 06, 2023 5:19 pm
by BDukes
Looks like a couple of issues.

Load scenario. Jump to unit level WRA. Notice they've been adjusted via your script. The arrow WRA calculation is off. They still launch at max range (system default), so not sure if custom values are really working.

Further questions:

What is the solution for when the best weapon is expended (total and aircraft) and the adversary is still calculating against it? Obviously looking into mags or even at the aircraft object is a bit too cheaty. Even if you could you would have to keep running a script against it as the loadout changes?

If my thinking about the shortcoming is correct I'm not quite sure yet if you're better off just setting to 25% of max range (or NEZ if you're a hardo).

Mike

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Tue Feb 07, 2023 6:19 am
by musurca
BDukes wrote: Mon Feb 06, 2023 5:19 pm Looks like a couple of issues.
Load scenario. Jump to unit level WRA. Notice they've been adjusted via your script. The arrow WRA calculation is off. They still launch at max range (system default), so not sure if custom values are really working.
Hey Mike! Thanks for finding that issue -- currently the script isn't changing the WRA of a Group into which units have been placed. Will dig into a fix for that. However, if you switch off group view and check the WRA on individual units, you should see that the Arrow was set to what the script estimates as its best Decision Range (~57.6nm is what I saw).

(Which might be a bit conservative at the moment. I'm also currently tweaking the DR estimation model to try to refine that number and should have an update soon, stay tuned.)
BDukes wrote: Mon Feb 06, 2023 5:19 pm Further questions: What is the solution for when the best weapon is expended (total and aircraft) and the adversary is still calculating against it? Obviously looking into mags or even at the aircraft object is a bit too cheaty. Even if you could you would have to keep running a script against it as the loadout changes?

If my thinking about the shortcoming is correct I'm not quite sure yet if you're better off just setting to 25% of max range (or NEZ if you're a hardo).
If I get what you're asking, this script isn't intended (well, not yet, anyway) to be a complete replacement for the BVR logic -- the main idea is to initialize the WRA ranges as if the pilot had gotten a briefing based on intelligence on the enemy, and had a big number printed on his kneeboard. If the best weapon has been expended, ideally this WRA/Doctrine combo has put the pilot in the best possible position to either RTB Winchester without getting trapped in the engagement, or else continue to the merge.

In theory this will be an improvement on 25% of max range/NEZ because the enemy's range is taken into effect and you'll see more survivors from encounters (over prioritizing getting the kill with that missile); in practice it definitely needs more testing to get there.

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Tue Feb 07, 2023 10:50 pm
by musurca
BDukes wrote: Mon Feb 06, 2023 5:19 pm Looks like a couple of issues.
Hey Mike -- I've updated the script in the original post. If you have some time, feel free to give it another go. I wasn't able to adjust the group WRA (I think this actually might be a CMO issue and will be reporting it), but the individual unit WRAs are adjusted correctly and should override the group setting. I ran it on the scenario you attached and the results are much more reasonable; the F-15Cs still have a pretty bad day, but they at least get a salvo of missiles off, and even occasionally knock off one of the Flankers. The script suggests that the 'best' WRA for the AIM-120Ds is max range, and for the Arrows it's about ~85.5nm. (In general, if the script suggests a WRA of max range, it's a strong indication that the unit is significantly outmatched by the enemy in this scenario, all other things being equal.)

If anyone's interested, the way this model is evaluated has changed significantly. The script calculates the best Decision Range (DR) at which to fire a missile such that DR = MAR + X. We estimate MAR as 50% of the enemy missile range*, then solve for X via a very simplified model of BVR attack geometry, as seen below (with apologies for my handwriting):

Image

Now let's plug in an example. Let's say the ownship and enemy ground speed are both 920kts, our missile's ground speed is 2600kts**, our sensor FOV is 50-degrees, the missile goes active when 10nm from the target, and the MAR is 24nm. (We're assuming both a/c are at the same altitude because we're only customizing the WRA once, and not in every situation.)

This gives us an X = 17.8nm. We then add 2nm to that as padding, because we need time and space to make our escape maneuver, which yields a final DR = 43.8nm.

* Because of the new energy model, there's no simple analytical solution to calculate this more exactly without access to more information from the DB than is currently available via Lua. 50% puts us in the neighborhood -- and for our purposes, it's entirely sufficient so long as we're placing a conservative upper bound on what that number might be. (But open to ideas for improvement here, because it's the value that has the largest effect on the result.)

** But of course it will be less than 2600kts because of drag in the new energy model. See footnote above as to why it's hard-ish to solve this exactly. I ran some iterative analysis in Excel using back-of-the-napkin estimates for the drag coefficient, and settled on using 85% of the max ground speed as the average speed for the relevant time period. Please pipe up if you have ways to refine this given the limited information we have access to.

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Wed Feb 08, 2023 12:04 am
by BDukes
First, great job working this Musurca. I'll use it.

My arrow shoot range is 87.5 and Amraam is max. Results are generally bad for the F-15s, and occasionally a flanker just gets nailed by the Amraam. the script has improved.

Just as a control, I did a NEZ shot as well. The burn for the arrow is long, and seems NEZ is close to its full range which makes me question the CMO space-time continuum or I need to research and see if this is one of the weapons that benefits from the latest change.I need to test with others as well.

Sounds like the devs have a bug to fix too.

Keep up the good work! I will report more as I tinker with this.

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Wed Feb 08, 2023 12:17 pm
by morphin
I hav also an suggestion:

Maybe add a dice roll for the AI Side for a little variability in WRA? For example one dice roll for probability of occurrence, then if this true add/subtract a random value from 0-15% from WRA for variabiilty and then one dice roll for the sign?

Re: BETTER_BVR - a better WRA default for BVR combat

Posted: Thu Feb 09, 2023 1:54 am
by musurca
morphin wrote: Wed Feb 08, 2023 12:17 pm Maybe add a dice roll for the AI Side for a little variability in WRA? For example one dice roll for probability of occurrence, then if this true add/subtract a random value from 0-15% from WRA for variabiilty and then one dice roll for the sign?
Hi morphin -- that's a great idea, thanks. I'll think about how to add an option for sides having unreliable intelligence about each other in the next version of the script.

In the meantime, if you want to try this right away, you can just add the following at line 197 (EDIT: just revised it to fix a bug):

Code: Select all

local player_side = ScenEdit_PlayerSide()
local is_friendly_side = false
local is_player_side = (side.name == player_side)
if not is_player_side then
    is_friendly_side = (ScenEdit_GetSidePosture(side.name, player_side) == "F")
end

if is_friendly_side or is_player_side then
    -- perturb AI knowledge of player-allied sides' maximum range by +/- 15%
    local VARIANCE_PCT = 0.15
    local range_mod = (2*VARIANCE_PCT*math.random() - VARIANCE_PCT) * max_range
    max_range = max_range + range_mod
end